Route Naldo brain through OpenCode free models
Browse files- README.md +61 -371
- gemini_bot_stack.py +32 -79
- minecraft_stack.py +718 -773
- naldo_opencode.py +348 -0
- naldo_opencode_bridge.py +321 -0
- tests/test_agent.py +3 -3
- tests/test_autonomy_stability.py +157 -0
- tests/test_naldo_opencode.py +271 -0
- voyager_runner.py +35 -191
- voyager_stack.py +0 -0
README.md
CHANGED
|
@@ -10,328 +10,56 @@ app_file: app.py
|
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
# Programa —
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
-
|
| 63 |
-
Curriculum e o Skill Manager. Os dois papéis são curtos e frequentes; um
|
| 64 |
-
segundo exemplar do modelo grande só somaria latência.
|
| 65 |
-
- **Embeddings** — `Qwen/Qwen3-Embedding-0.6B-GGUF`, alias `text-embedding-ada-002`.
|
| 66 |
-
|
| 67 |
-
Como os dois Spaces não compartilham rede, a URL do cérebro é a **pública**
|
| 68 |
-
(`https://erinaldordso-mine.hf.space/v1`), nunca loopback.
|
| 69 |
-
|
| 70 |
-
Os aliases antigos continuam aceitos apenas para compatibilidade de checkpoints
|
| 71 |
-
e clientes: `granite-4.0-h-tiny` resolve para o modelo auxiliar e
|
| 72 |
-
`voyager-qwen3-coder` para o principal.
|
| 73 |
-
|
| 74 |
-
### Brain lock
|
| 75 |
-
|
| 76 |
-
`voyager_runner.py` recusa iniciar se a tupla
|
| 77 |
-
`(perfil, base_url, modelo, modelo_auxiliar)` não for exatamente a esperada.
|
| 78 |
-
Sem isso, um deploy poderia apontar silenciosamente para outro provedor. A
|
| 79 |
-
checagem tem escape: definir `VOYAGER_BRAIN_LOCK=off` no Space pula o lock, o
|
| 80 |
-
que permite subir o bot mesmo com o Mine fora do ar.
|
| 81 |
-
|
| 82 |
-
## Política de exploração do Action Agent
|
| 83 |
-
|
| 84 |
-
O mundo é um **bolo de camadas** (`WORLD_FLAT_LAYERS` em `minecraft_stack.py`).
|
| 85 |
-
Todo minério é uma **camada horizontal contínua numa altura fixa**, e o bot pisa
|
| 86 |
-
na grama em `y=63`:
|
| 87 |
-
|
| 88 |
-
| Camada | y | Distância da superfície |
|
| 89 |
-
|---|---|---|
|
| 90 |
-
| `emerald_ore` | 58 | 5 |
|
| 91 |
-
| `lapis_ore` | 57 | 6 |
|
| 92 |
-
| `diamond_ore` | 56 | 7 |
|
| 93 |
-
| `redstone_ore` | 54-55 | 8-9 |
|
| 94 |
-
| `gold_ore` | 52-53 | 10-11 |
|
| 95 |
-
| `iron_ore` | 48-51 | 12-15 |
|
| 96 |
-
| `copper_ore` | 44-47 | 16-19 |
|
| 97 |
-
| `coal_ore` | 40-43 | 20-23 |
|
| 98 |
-
| `deepslate_*_ore` | −57 até −42 | ~100 (repetição no fundo) |
|
| 99 |
-
|
| 100 |
-
Entre a superfície e o minério mais fundo há **81 camadas de pedra**. Logo um
|
| 101 |
-
minério que o bot enxerga está **sempre logo abaixo dele** — e andar na
|
| 102 |
-
horizontal nunca muda o `y`, então um passeio horizontal é incapaz de alcançar
|
| 103 |
-
uma camada que está de 5 a 23 blocos reto para baixo. As alturas exatas são
|
| 104 |
-
afirmadas no Action prompt e conferidas contra o mundo por
|
| 105 |
-
`tests/test_voyager_mineblock_depth.py`.
|
| 106 |
-
|
| 107 |
-
Por isso `exploreUntil` tem dois modos, e o modo é escolhido pelo lugar onde o
|
| 108 |
-
alvo realmente está:
|
| 109 |
-
|
| 110 |
-
| Alvo | Direção | Comportamento |
|
| 111 |
-
|---|---|---|
|
| 112 |
-
| Bloco de superfície (tronco, grama, areia, bioma) | `y = 0` | Caminhada horizontal, como sempre |
|
| 113 |
-
| Minério ou qualquer bloco subterrâneo | `y = -1` ou `"down"` | Escava uma escada na própria coluna, um bloco por vez |
|
| 114 |
-
|
| 115 |
-
A descida é **controlada**: o bot mina o bloco sob os próprios pés em vez de
|
| 116 |
-
pedir ao pathfinder um destino mais baixo, nunca cai, e para em 32 blocos de
|
| 117 |
-
profundidade. Como ele permanece na própria coluna X/Z, cada camada minerada é
|
| 118 |
-
uma célula por onde consegue voltar — e `allow1by1towers` fica ligado em
|
| 119 |
-
Creative justamente para que ele consiga sair do próprio poço. O próprio
|
| 120 |
-
`mineBlock` também desce até a camada quando ela está fora de alcance, então o
|
| 121 |
-
modelo não precisa orquestrar a descida à mão.
|
| 122 |
-
|
| 123 |
-
> Nota histórica: uma versão anterior forçava `dy = 0` para toda exploração em
|
| 124 |
-
> Creative, para impedir que o `Vec3(0,-1,0)` do upstream derrubasse o bot de
|
| 125 |
-
> uma beirada. Isso resolvia aquele problema e criava um pior: o Action prompt
|
| 126 |
-
> mandava cavar para baixo e a primitiva reescrevia o pedido em um passeio
|
| 127 |
-
> horizontal, então todo minério subterrâneo falhava. Medido no bot ao vivo:
|
| 128 |
-
> 8 dos últimos 12 fracassos eram minérios subterrâneos (ferro, ouro, lápis,
|
| 129 |
-
> redstone, cobre, diamante, esmeralda, quartzo), enquanto as tarefas que
|
| 130 |
-
> concluíam eram todas alcançáveis na superfície. A guarda contra regressão
|
| 131 |
-
> está em `tests/test_voyager_vertical_exploration.py`.
|
| 132 |
-
|
| 133 |
-
> Nota histórica 2 — **o piso que se movia**. A primeira tentativa de deixar o
|
| 134 |
-
> `mineBlock` cavar usou `candidate.position.y >= Math.floor(bot.y) - 2`. O
|
| 135 |
-
> problema é que esse piso era medido **a partir do bot**, então descia junto
|
| 136 |
-
> com ele: em `y=63` o filtro era `y >= 61` e recusava o ferro em `y=48`, que
|
| 137 |
-
> estava visível no raio de 32. O bot lançava `use exploreUntil to descend`, o
|
| 138 |
-
> modelo descia um bloco, e o piso descia um bloco junto — a janela recuava na
|
| 139 |
-
> mesma velocidade que o bot. **Por construção o minério era inalcançável.**
|
| 140 |
-
> Sintoma: `completed_count` parado em 145 por horas, 51 restarts do watchdog,
|
| 141 |
-
> `mine.distinct_count = 2` (só `diamond_pickaxe` e `obsidian`, nenhum minério
|
| 142 |
-
> real) e `explore.distinct_count = 2` (só horizontal). A correção troca o piso
|
| 143 |
-
> relativo por um **orçamento de profundidade absoluto** (`MAX_DIG_DEPTH = 28`)
|
| 144 |
-
> e faz a primitiva escavar uma escada até a camada. Guarda em
|
| 145 |
-
> `tests/test_voyager_mineblock_depth.py`, que lê as alturas do próprio
|
| 146 |
-
> `minecraft_stack.py` para que o prompt não possa divergir do mundo.
|
| 147 |
-
|
| 148 |
-
> Nota histórica 3 — **o boot que nunca terminava**. Depois de corrigir o piso,
|
| 149 |
-
> o `completed_count` continuou parado em 145. A pista decisiva foi o log: ele
|
| 150 |
-
> terminava **exatamente** em `Loading Skill Manager from ...`, sem uma linha
|
| 151 |
-
> depois, com o processo vivo e `restart_count` subindo a cada ~25 min.
|
| 152 |
-
>
|
| 153 |
-
> A causa: `SkillManager.__init__` construía `Chroma(...)` **sem deadline
|
| 154 |
-
> nenhum**, com `embedding_function=OpenAIEmbeddings()`. No `langchain==0.0.350`
|
| 155 |
-
> esse cliente é o `openai==0.28.1`, cujo `Embedding.create` **não tem timeout
|
| 156 |
-
> default**. Como a base URL passou a ser o cérebro remoto, uma resposta travada
|
| 157 |
-
> de `/v1/embeddings` bloqueava o construtor para sempre — e como o construtor
|
| 158 |
-
> vem antes de `learn()`, o loop nunca chegava a propor tarefa: **nada falhava,
|
| 159 |
-
> nada retentava, nenhum contador se movia.** O watchdog de estagnação, que
|
| 160 |
-
> dispara a cada 900 s, parecia a causa e era o sintoma.
|
| 161 |
-
>
|
| 162 |
-
> O mesmo construtor terminava com um `assert` de sincronia. Um índice uma
|
| 163 |
-
> unidade fora de sincronia é **recuperável** — `skills.json` é a fonte da
|
| 164 |
-
> verdade e já traz código e descrição de cada skill —, mas o `assert`
|
| 165 |
-
> transformava isso num processo morto, exatamente o passo manual que a própria
|
| 166 |
-
> mensagem de erro mandava fazer. Agora o `__init__` reindexa o que falta e
|
| 167 |
-
> remove o que sobra.
|
| 168 |
-
>
|
| 169 |
-
> A correção: abertura sob deadline (`VECTORDB_OPEN_SECONDS = 180`) e timeout
|
| 170 |
-
> explícito na função de embedding (`EMBED_DEADLINE_SECONDS = 60`), de modo que
|
| 171 |
-
> uma dependência lenta vire exceção retentável em vez de bloqueio. Guarda em
|
| 172 |
-
> `tests/test_voyager_skillmanager_bounds.py`.
|
| 173 |
-
>
|
| 174 |
-
> **Lição de observabilidade:** diagnosticar isso de fora era impossível.
|
| 175 |
-
> `/voyager/status` remove `log_tail` de propósito, e o stream de log da
|
| 176 |
-
> Hugging Face servia uma janela ~35 h atrasada (terminava em
|
| 177 |
-
> `2026-09-29T00:24Z` com o boot atual em `2026-09-30T11:34Z`), então um runner
|
| 178 |
-
> travado e um saudável ficavam indistinguíveis. O `GET /voyager/log` lê o mesmo
|
| 179 |
-
> arquivo do status, sob demanda, com `?lines=` e `?find=`; a guarda está em
|
| 180 |
-
> `tests/test_voyager_log_observability.py`.
|
| 181 |
-
|
| 182 |
-
> Nota histórica 4 — **o mesmo bug, uma camada acima**. Com o construtor do
|
| 183 |
-
> `SkillManager` liberado, o loop finalmente chegou ao modelo (uma rodada real
|
| 184 |
-
> de `Curriculum Agent Question` / `Answer` aparece no log) e morreu assim no
|
| 185 |
-
> ciclo seguinte:
|
| 186 |
-
>
|
| 187 |
-
> ```
|
| 188 |
-
> [voyager] learning loop raised AssertionError: ; keeping the Minecraft
|
| 189 |
-
> connection and retrying in 120s (attempt 4)
|
| 190 |
-
> ```
|
| 191 |
-
>
|
| 192 |
-
> A mensagem **vazia** é a assinatura: `AssertionError` sem texto só vem de um
|
| 193 |
-
> `assert` puro. A linha exata estava em `run_qa`:
|
| 194 |
-
>
|
| 195 |
-
> ```python
|
| 196 |
-
> if docs_and_scores and docs_and_scores[0][1] < 0.05:
|
| 197 |
-
> question_cached = docs_and_scores[0][0].page_content
|
| 198 |
-
> assert question_cached in self.qa_cache # <-- sem mensagem
|
| 199 |
-
> ```
|
| 200 |
-
>
|
| 201 |
-
> O vetor de `qa_cache` e o `qa_cache.json` divergem assim que um dos dois é
|
| 202 |
-
> reconstruído ou escrito pela metade, e então a busca por similaridade devolve
|
| 203 |
-
> uma pergunta que o JSON não tem mais. O `assert` puro **escapava de `learn()`**
|
| 204 |
-
> — o `try` interno só envolve `rollout()` — e chegava ao runner, que retentava
|
| 205 |
-
> a cada 120 s. Novamente: erro a cada ciclo, nenhum avanço, `completed_count`
|
| 206 |
-
> parado.
|
| 207 |
-
>
|
| 208 |
-
> Um acerto de cache que não existe no JSON **não é corrupção**, é um miss:
|
| 209 |
-
> recalcula-se a resposta e segue. Agora o acerto ausente é descartado (com o
|
| 210 |
-
> vetor obsoleto removido) e a busca tem deadline (`QA_LOOKUP_SECONDS = 45`),
|
| 211 |
-
> porque `similarity_search` embeda a pergunta e essa chamada também não tinha
|
| 212 |
-
> limite. `run_qa` não é um método de decisão atestado, então a mudança não
|
| 213 |
-
> toca a atestação; `tests/test_voyager_qacache_selfheal.py` fixa as três
|
| 214 |
-
> propriedades (sem `assert`, miss vira recálculo, busca limitada).
|
| 215 |
-
|
| 216 |
-
> Nota histórica 5 — **o deadline que não valia nada**. Depois das três
|
| 217 |
-
> correções de `assert`, o runner parou de reiniciar em laço, mas ficou **343 s
|
| 218 |
-
> sem escrever uma linha** depois de `Loading Skill Manager`, com
|
| 219 |
-
> `action_model_messages = 0` e `curriculum_model_messages = 0` — ou seja,
|
| 220 |
-
> `learn()` nunca foi alcançado. O detalhe que denuncia o problema: **já
|
| 221 |
-
> existia um deadline de 180 s** naquele caminho, e mesmo assim pendurou.
|
| 222 |
-
>
|
| 223 |
-
> A causa, reproduzida fora do Space:
|
| 224 |
-
>
|
| 225 |
-
> ```python
|
| 226 |
-
> with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
| 227 |
-
> future = pool.submit(chamada_que_trava)
|
| 228 |
-
> try:
|
| 229 |
-
> return future.result(timeout=180)
|
| 230 |
-
> except concurrent.futures.TimeoutError:
|
| 231 |
-
> raise RuntimeError("deadline hit")
|
| 232 |
-
> ```
|
| 233 |
-
>
|
| 234 |
-
> `future.result(timeout=...)` levanta `TimeoutError` no prazo, sim. Mas sair do
|
| 235 |
-
> bloco `with` chama `shutdown(wait=True)`, que faz **join no worker** — a mesma
|
| 236 |
-
> chamada que está travada. O prazo dispara e o join bloqueia logo atrás, para
|
| 237 |
-
> sempre. Saída do experimento real:
|
| 238 |
-
>
|
| 239 |
-
> ```
|
| 240 |
-
> [2.0s] TimeoutError raised as expected
|
| 241 |
-
> (nunca imprime "RuntimeError propagated")
|
| 242 |
-
> (nunca imprime "reached end")
|
| 243 |
-
> ```
|
| 244 |
-
>
|
| 245 |
-
> O `except` nunca roda, então o erro é indistinguível de um travamento. O
|
| 246 |
-
> deadline era decorativo. Quatro pontos tinham exatamente essa forma:
|
| 247 |
-
> `_open_vectordb`, `_open_qa_vectordb`, a busca do `qa_cache` e `_bounded_step`
|
| 248 |
-
> (que embrulha o `env.step("")` do boot do `learn()` — o mais provável dos
|
| 249 |
-
> quatro, por estar no caminho quente).
|
| 250 |
-
>
|
| 251 |
-
> A correção usa thread **daemon** e, no timeout, **abandona** a thread em vez
|
| 252 |
-
> de esperá-la: `worker.join(seconds)`, `if worker.is_alive(): raise`. Uma
|
| 253 |
-
> thread daemon não segura o interpretador vivo, então abandoná-la é seguro.
|
| 254 |
-
> Levantar (em vez de degradar para um índice quebrado) é deliberado — o
|
| 255 |
-
> supervisor do runner reinicia com backoff, então um endpoint de embedding
|
| 256 |
-
> realmente inalcançável vira retentativa em vez de loop meio-funcional.
|
| 257 |
-
> `tests/test_voyager_executor_deadline.py` fixa as propriedades **e roda a
|
| 258 |
-
> prova viva do bug** num subprocesso, para que a regressão não possa voltar
|
| 259 |
-
> silenciosamente.
|
| 260 |
-
|
| 261 |
-
## Só o servidor de Minecraft
|
| 262 |
-
|
| 263 |
-
O cérebro mora no Space `Erinaldordso/Mine`. Este Space roda o servidor Java, a
|
| 264 |
-
ponte Mineflayer, o runner do Voyager e o proxy do Gemini — e **nenhum peso de
|
| 265 |
-
modelo**.
|
| 266 |
-
|
| 267 |
-
Por que: o Qwen principal e o Granite auxiliar eram carregados aqui e ninguém
|
| 268 |
-
os usava. O runner do Voyager define `OPENAI_API_BASE` para o endpoint do Mine,
|
| 269 |
-
então tanto o chat quanto os embeddings dele já saíam deste Space; o único
|
| 270 |
-
consumidor local restante era o embedding do Gemini_Bot. Manter dois GGUF
|
| 271 |
-
residentes por causa disso consumia a RAM que o servidor Java precisa — e uma
|
| 272 |
-
carga local que falhava aparecia como `aux_ready: false` em `/health`, o que se
|
| 273 |
-
lê como "bot quebrado" quando na verdade é "carregador sem uso".
|
| 274 |
-
|
| 275 |
-
`MINECRAFT_SERVER_ONLY=1` (default) faz cada ponto de entrada que poderia tocar
|
| 276 |
-
um peso falhar com o host correto na mensagem, em vez de iniciar um download de
|
| 277 |
-
18 GB na primeira requisição:
|
| 278 |
-
|
| 279 |
-
| Superfície | Comportamento |
|
| 280 |
-
|---|---|
|
| 281 |
-
| `_load_model`, `_load_aux_model`, `_load_embed_model` | `RuntimeError` nomeando `BRAIN_BASE_URL`, antes de qualquer `hf_hub_download`/`Llama(` |
|
| 282 |
-
| `_startup` | não cria nenhuma das três threads de preload; servidor, Voyager e Gemini sobem normalmente |
|
| 283 |
-
| `_select_chat_model` | recusa com redirect, em vez de tentar carregar |
|
| 284 |
-
| `POST /v1/embeddings` | recusa **antes** de ler o corpo, para o erro ler como "host errado" e não "requisição inválida" |
|
| 285 |
-
| `GET /health` | `server_only: true`, `local_models: false` e o mapa de papéis para o host real — sem `aux_ready`/`ready` |
|
| 286 |
-
| `GET /status` | idem, mantendo as métricas de RAM/CPU, que continuam verdadeiras |
|
| 287 |
-
| `GET /v1/models` | lista vazia + `brain_base_url`: anunciar um modelo que este Space recusa servir manda o cliente para o host errado com base numa descoberta bem-sucedida |
|
| 288 |
-
|
| 289 |
-
Os embeddings do Gemini_Bot passam a apontar para o Mine
|
| 290 |
-
(`GEMINI_VOYAGER_EMBED_BASE`). A troca é segura porque **os dois Spaces carregam
|
| 291 |
-
o mesmo `Qwen/Qwen3-Embedding-0.6B-GGUF`** (alias `text-embedding-ada-002`) e os
|
| 292 |
-
dois devolvem vetores de **1024 dimensões**, então nenhum índice Chroma
|
| 293 |
-
existente muda de significado. `EMBED_BASE` é usado só para `/embeddings`, nunca
|
| 294 |
-
para chat — a guarda em `tests/test_voyager_server_only.py` verifica isso, além
|
| 295 |
-
de cada recusa e cada default que apontava para `127.0.0.1:7860`.
|
| 296 |
-
|
| 297 |
-
## Defesas contra o congelamento silencioso
|
| 298 |
-
|
| 299 |
-
Depois da correção acima o bot passou a se mover, mas apareceu uma falha de
|
| 300 |
-
outra natureza, sem nenhum sinal de erro: **o processo continuava vivo
|
| 301 |
-
(`running=true`, `restart_count=0`), todos os contadores de erro em zero, e
|
| 302 |
-
nenhuma chamada ao modelo em 45 minutos.** Não era lentidão nem falha — era um
|
| 303 |
-
**bloqueio**. Um loop que falha deixa contadores; um bloqueio não deixa nada.
|
| 304 |
-
|
| 305 |
-
A causa estava no caminho de recuperação: o upstream espera processos e responde
|
| 306 |
-
a HTTP sem limite de tempo, e uma dependência travada transforma a própria
|
| 307 |
-
recuperação na trava.
|
| 308 |
-
|
| 309 |
-
| Onde | Antes | Agora |
|
| 310 |
-
|---|---|---|
|
| 311 |
-
| `SubprocessMonitor.stop()` | `self.process.wait()` sem limite — um node travado ignora SIGTERM e o wait nunca volta | `wait(timeout=10)`, depois `kill()` |
|
| 312 |
-
| `SubprocessMonitor.run()` | `self.ready_event.wait()` sem limite — se o node nunca imprime a linha de ready, trava para sempre | `wait(timeout=60)`, então erro explícito |
|
| 313 |
-
| `env.step("")` no início de `learn()`/`inference()` | sem limite | `_bounded_step("", timeout=120)` |
|
| 314 |
-
| Recuperação de exceção no `voyager.py` | `env.step("")` sem limite no lugar do reset duro | mesmo helper, com `timeout=60`, caindo no reset duro |
|
| 315 |
-
| Loop `learn()` do runner | retry infinito (`MAX_ATTEMPTS=0`); um bloqueio nunca é detectado | watchdog de estagnação: sem tarefa concluída em 15 min, o runner sai para o supervisor reiniciar |
|
| 316 |
-
|
| 317 |
-
O `bridge.reset()` chama `mineflayer.stop()`, e o `learn()` começa por um
|
| 318 |
-
`env.reset()` — ou seja, **a recuperação era exatamente o ponto de travamento**.
|
| 319 |
-
O watchdog lê o progresso dos arquivos de checkpoint (não de uma linha de log,
|
| 320 |
-
que pode não existir) e só arma depois da primeira tarefa concluída, então um
|
| 321 |
-
boot frio nunca é interrompido. O limite é ajustável por
|
| 322 |
-
`VOYAGER_STALL_SECONDS`.
|
| 323 |
-
|
| 324 |
-
O watchdog observa **três** arquivos — `completed_tasks.json`,
|
| 325 |
-
`failed_tasks.json` e `skill/skills.json` —, os mesmos que
|
| 326 |
-
`voyager_learning._signature()` já usava para definir progresso. Vigiar só as
|
| 327 |
-
tarefas concluídas o deixava cego para um bot que trabalha e falha: ele
|
| 328 |
-
reciclava um runner no meio de uma tentativa. **Uma falha também é progresso**
|
| 329 |
-
— o bot agiu e o Critic julgou —, então ela reinicia o relógio. Só um loop que
|
| 330 |
-
não faz *nada* pela janela inteira é estagnação.
|
| 331 |
-
|
| 332 |
-
A guarda contra regressão está em `tests/test_voyager_stall_contract.py`, que
|
| 333 |
-
clona o upstream pinado, aplica o patcher e verifica o **código gerado** — não o
|
| 334 |
-
texto do patch, que poderia virar um no-op silencioso.
|
| 335 |
|
| 336 |
## Embeddings
|
| 337 |
|
|
@@ -393,12 +121,11 @@ O endpoint mantém a compatibilidade HTTP de chat e embeddings para os clientes.
|
|
| 393 |
## Segundo jogador: Gemini_Bot
|
| 394 |
|
| 395 |
`Gemini_Bot` executa uma segunda instância completa do Voyager no mesmo servidor
|
| 396 |
-
Java 1.19, com processo Mineflayer e checkpoint próprios.
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
real no mundo ou pelo inventário.
|
| 402 |
|
| 403 |
O Curriculum permanece no modo automático do Voyager: cada IA observa o estado
|
| 404 |
atual, consulta seu checkpoint e decide o próximo objetivo. Não existe sequência
|
|
@@ -438,14 +165,18 @@ blocos, não teleporta e não altera a exploração horizontal do Curriculum.
|
|
| 438 |
- Controle de inicialização: `GEMINI_BOT_AUTOSTART=0` desativa somente o
|
| 439 |
segundo bot. O Naldo_Bot e o mundo persistente seguem independentes.
|
| 440 |
|
| 441 |
-
|
| 442 |
|
| 443 |
`https://erinaldordso-minecraft-friends-ai-zerogpu.hf.space/v1`
|
| 444 |
|
| 445 |
-
Modelo de chat:
|
| 446 |
|
| 447 |
`granite-4.0-h-tiny`
|
| 448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
Modelo de embeddings:
|
| 450 |
|
| 451 |
`text-embedding-ada-002`
|
|
@@ -545,44 +276,3 @@ Validação offline, sem iniciar modelos ou servidor de jogo:
|
|
| 545 |
O contrato do runtime (decisões upstream, adaptação declarada do retry do
|
| 546 |
curriculum e patch aplicado sobre a árvore oficial) é verificado por
|
| 547 |
`tests/test_voyager_official_contract.py` e `tests/test_voyager_patch_application.py`.
|
| 548 |
-
|
| 549 |
-
## Terceiro bot: AltoClef com OpenCode
|
| 550 |
-
|
| 551 |
-
`OpenCode_Bot` usa **AltoClef 0.19 / Baritone**, do fork
|
| 552 |
-
[MiranCZ/altoclef](https://github.com/MiranCZ/altoclef). O motor tem tarefas reais
|
| 553 |
-
para encontrar o stronghold, abrir o portal, enfrentar o dragão e entrar no portal
|
| 554 |
-
de retorno. É uma alternativa ao Voyager focada em concluir o jogo; não depende
|
| 555 |
-
de gerar JavaScript para cada movimento. Os dois jogadores Voyager existentes
|
| 556 |
-
mantêm seus checkpoints.
|
| 557 |
-
|
| 558 |
-
O cliente Fabric **1.19.4** conecta ao servidor **1.19** pelo ViaProxy já existente
|
| 559 |
-
em `127.0.0.1:25568`. Não há alteração da versão do mundo. Xvfb e Mesa llvmpipe
|
| 560 |
-
renderizam o cliente em CPU, a 20 FPS, com heap de 2 GiB e dois processadores
|
| 561 |
-
ativos. Os pesos de IA continuam exclusivamente no Space Mine.
|
| 562 |
-
|
| 563 |
-
OpenCode **1.18.34** recebe observações reais para supervisionar o progresso.
|
| 564 |
-
O provedor ativo é o endpoint Mine do usuário, sem API key, temperatura 0 e
|
| 565 |
-
contexto compatível com Qwen (16K) e Granite (8K). Os modelos gratuitos do
|
| 566 |
-
catálogo OpenCode apareceram na consulta, mas uma chamada genérica a
|
| 567 |
-
`opencode/big-pickle` retornou HTTP 403. Eles não são o cérebro padrão e nenhuma
|
| 568 |
-
disponibilidade contínua é presumida. Uma indisponibilidade do modelo não
|
| 569 |
-
interrompe as tarefas determinísticas do AltoClef.
|
| 570 |
-
|
| 571 |
-
O mundo atual continua **Creative / Peaceful**. O equipamento inicial ausente
|
| 572 |
-
é fornecido somente ao novo jogador, sem limpar inventários. Isso é uma
|
| 573 |
-
conclusão assistida em Creative, não um speedrun de Survival. Não são usados
|
| 574 |
-
comandos para matar o dragão, construir um portal artificial ou teleportar o bot.
|
| 575 |
-
`goal_completed` exige que o cliente tenha entrado no End e observado os créditos.
|
| 576 |
-
|
| 577 |
-
Estado e logs: `/opencode-bot/status`, `/opencode-bot/log`. Dados persistentes:
|
| 578 |
-
`/persist/minecraft-friends/altoclef/`; cliente, configuração e observações
|
| 579 |
-
sobrevivem a reinícios. `ALTOCLEF_AUTOSTART=0` desativa o início automático.
|
| 580 |
-
Falhas de inicialização têm backoff de 30 a 480 segundos. O adaptador Java
|
| 581 |
-
usa as classes e os métodos conferidos no JAR AltoClef e nas mappings Yarn
|
| 582 |
-
1.19.4. Para recompilá-lo com Java 17: `python altoclef/build.py`.
|
| 583 |
-
|
| 584 |
-
Revisão: Phoenix foi descartado porque seu pipeline `DefeatEnderDragon` usa
|
| 585 |
-
`UnsupportedEndgameTask` e lança `NOT_IMPLEMENTED`. O AltoClef tem implementação
|
| 586 |
-
de portal e combate; sua conclusão ainda precisa ser observada neste mundo.
|
| 587 |
-
O toolkit dos Voyager inclui 64 cobblestones para a navegação subir torres quando
|
| 588 |
-
os jogadores estão abaixo da superfície; em Creative, minerar não gera drops.
|
|
|
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Programa — endpoint CPU para Voyager
|
| 14 |
+
|
| 15 |
+
Endpoint OpenAI-compatible dedicado ao projeto original MineDojo/Voyager.
|
| 16 |
+
|
| 17 |
+
## Endpoint local de compatibilidade (Granite)
|
| 18 |
+
|
| 19 |
+
O endpoint OpenAI-compatible público continua disponível para clientes antigos e
|
| 20 |
+
para os componentes locais auxiliares. Ele não é mais o cérebro generativo do
|
| 21 |
+
`Naldo_Bot`; o Naldo usa OpenCode conforme a seção seguinte.
|
| 22 |
+
|
| 23 |
+
- GGUF oficial: `ibm-granite/granite-4.0-h-tiny-GGUF`
|
| 24 |
+
- Arquivo: `granite-4.0-h-tiny-Q4_K_M.gguf`
|
| 25 |
+
- Revisão fixada: `08d5a8a9741dd5c1a95d2d39e25253226aa1464e`
|
| 26 |
+
- Quantização: `Q4_K_M`
|
| 27 |
+
- Arquitetura: Granite 4.0 H-Tiny MoE/Mamba2, 7B totais e ~1B parâmetros ativos
|
| 28 |
+
- Contexto operacional do Voyager: 8.192 tokens
|
| 29 |
+
- Saída padrão: 384 tokens
|
| 30 |
+
- Saída máxima: 512 tokens
|
| 31 |
+
- Alias principal: `granite-4.0-h-tiny`
|
| 32 |
+
- CPU: 16 threads de decode e 16 threads de prefill/batch
|
| 33 |
+
|
| 34 |
+
Os aliases antigos permanecem aceitos apenas para compatibilidade de clientes e
|
| 35 |
+
continuam roteando para o Granite local. O Voyager do Naldo não usa esses aliases
|
| 36 |
+
para geração.
|
| 37 |
+
|
| 38 |
+
## Cérebro do Naldo_Bot: OpenCode
|
| 39 |
+
|
| 40 |
+
Action, Curriculum, Critic e Skill Manager do `Naldo_Bot` usam uma ponte
|
| 41 |
+
OpenAI-compatible local em `127.0.0.1:7864`, que traduz as chamadas do Voyager
|
| 42 |
+
para um servidor `opencode serve` preso ao loopback em `127.0.0.1:7865`.
|
| 43 |
+
|
| 44 |
+
- OpenCode pinado: `opencode-ai@1.18.33`, instalado localmente no runtime sem `npm -g`.
|
| 45 |
+
- Provider: `opencode`.
|
| 46 |
+
- Modelo principal: `muse-spark-1.3-contributor-free`.
|
| 47 |
+
- Fallbacks: `fledge-alpha-free` e `nemotron-3-ultra-free`, somente do provider OpenCode gratuito.
|
| 48 |
+
- Nenhuma credencial do usuário é copiada; o processo recebe HOME/XDG próprios no runtime.
|
| 49 |
+
- As mensagens de inferência enviam `tools: {}`. No OpenCode 1.18.33 isso foi validado
|
| 50 |
+
sem execução de bash/arquivos; permissões customizadas de sessão não são usadas porque
|
| 51 |
+
o Zen gratuito as rejeita com `FreeTierError`.
|
| 52 |
+
- O bridge não inventa suporte a `temperature`/`max_tokens`: registra os valores pedidos
|
| 53 |
+
e preserva a resposta integral, evitando cortar JavaScript do Action Agent.
|
| 54 |
+
- Embeddings continuam locais em `127.0.0.1:7860/v1/embeddings`.
|
| 55 |
+
- Se OpenCode ou bridge morrerem, o supervisor interrompe o Naldo e recupera o cérebro
|
| 56 |
+
antes de religar o bot; não existe fallback generativo para Granite ou Gemini.
|
| 57 |
+
|
| 58 |
+
O Curriculum em Creative continua usando o estado real e os checkpoints persistentes.
|
| 59 |
+
A conclusão da tarefa continua exigindo verificação independente do mundo.
|
| 60 |
+
|
| 61 |
+
Referência do modelo:
|
| 62 |
+
https://huggingface.co/ibm-granite/granite-4.0-h-tiny-GGUF
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
## Embeddings
|
| 65 |
|
|
|
|
| 121 |
## Segundo jogador: Gemini_Bot
|
| 122 |
|
| 123 |
`Gemini_Bot` executa uma segunda instância completa do Voyager no mesmo servidor
|
| 124 |
+
Java 1.19, com processo Mineflayer e checkpoint próprios. Curriculum, Action,
|
| 125 |
+
Critic e Skill Manager consultam o Gemini CLI por um proxy local exclusivo;
|
| 126 |
+
somente os embeddings usam o modelo local. `Naldo_Bot` usa exclusivamente o
|
| 127 |
+
bridge OpenCode descrito acima para geração. Ambos validam tarefas Creative pela
|
| 128 |
+
mudança real no mundo ou pelo inventário.
|
|
|
|
| 129 |
|
| 130 |
O Curriculum permanece no modo automático do Voyager: cada IA observa o estado
|
| 131 |
atual, consulta seu checkpoint e decide o próximo objetivo. Não existe sequência
|
|
|
|
| 165 |
- Controle de inicialização: `GEMINI_BOT_AUTOSTART=0` desativa somente o
|
| 166 |
segundo bot. O Naldo_Bot e o mundo persistente seguem independentes.
|
| 167 |
|
| 168 |
+
API pública de compatibilidade:
|
| 169 |
|
| 170 |
`https://erinaldordso-minecraft-friends-ai-zerogpu.hf.space/v1`
|
| 171 |
|
| 172 |
+
Modelo de chat público (não é o cérebro do Naldo):
|
| 173 |
|
| 174 |
`granite-4.0-h-tiny`
|
| 175 |
|
| 176 |
+
Cérebro interno do Naldo:
|
| 177 |
+
|
| 178 |
+
`opencode/muse-spark-1.3-contributor-free` via `127.0.0.1:7864/v1`
|
| 179 |
+
|
| 180 |
Modelo de embeddings:
|
| 181 |
|
| 182 |
`text-embedding-ada-002`
|
|
|
|
| 276 |
O contrato do runtime (decisões upstream, adaptação declarada do retry do
|
| 277 |
curriculum e patch aplicado sobre a árvore oficial) é verificado por
|
| 278 |
`tests/test_voyager_official_contract.py` e `tests/test_voyager_patch_application.py`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gemini_bot_stack.py
CHANGED
|
@@ -12,10 +12,9 @@ import urllib.request
|
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
-
from voyager_stack import ROOT as VOYAGER_ROOT, SRC as VOYAGER_SRC, CKPT_DIR as NALDO_CKPT_DIR, VOYAGER, _wait_tcp
|
| 16 |
-
from voyager_runtime_contract import UPSTREAM_COMMIT, read_attestation
|
| 17 |
from voyager_learning import read_learning_state
|
| 18 |
-
from voyager_processes import stop_runner
|
| 19 |
|
| 20 |
ROOT = Path(os.environ.get("GEMINI_BOT_RUNTIME_HOME", "/tmp/gemini-bot-runtime"))
|
| 21 |
PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
|
|
@@ -39,7 +38,14 @@ def _node_version() -> int:
|
|
| 39 |
|
| 40 |
|
| 41 |
def _terminate(proc: subprocess.Popen[str] | None, timeout: int = 15) -> None:
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
def _read_json(path: Path) -> list[str]:
|
|
@@ -52,7 +58,6 @@ def _read_json(path: Path) -> list[str]:
|
|
| 52 |
|
| 53 |
class GeminiBotStack:
|
| 54 |
def __init__(self) -> None:
|
| 55 |
-
self.remote_mine = os.environ.get("GEMINI_BOT_BACKEND", "mine") == "mine"
|
| 56 |
self.lock = threading.RLock()
|
| 57 |
self.worker: threading.Thread | None = None
|
| 58 |
self.proc: subprocess.Popen[str] | None = None
|
|
@@ -157,39 +162,10 @@ class GeminiBotStack:
|
|
| 157 |
"GEMINI_BOT_SYSTEM_MD": str(workspace / ".gemini" / "system.md"),
|
| 158 |
"GEMINI_VOYAGER_MODEL": MODEL,
|
| 159 |
"GEMINI_VOYAGER_PROXY_PORT": str(PROXY_PORT),
|
| 160 |
-
|
| 161 |
-
# This Space no longer loads a local embedding model (see the
|
| 162 |
-
# SERVER_ONLY note in app.py), so the old default of
|
| 163 |
-
# http://127.0.0.1:7860/v1 would now answer 503. Mine serves the
|
| 164 |
-
# same Qwen3-Embedding-0.6B alias and the same 1024-dim vectors, so
|
| 165 |
-
# switching hosts cannot change any stored vector.
|
| 166 |
-
"GEMINI_VOYAGER_EMBED_BASE": os.environ.get(
|
| 167 |
-
"VOYAGER_BRAIN_BASE_URL", "https://erinaldordso-mine.hf.space/v1"
|
| 168 |
-
),
|
| 169 |
})
|
| 170 |
return env
|
| 171 |
|
| 172 |
-
def _mine_env(self) -> dict[str, str]:
|
| 173 |
-
"""No Gemini CLI or model weights are needed by the second runner."""
|
| 174 |
-
ROOT.mkdir(parents=True, exist_ok=True)
|
| 175 |
-
CKPT_DIR.mkdir(parents=True, exist_ok=True)
|
| 176 |
-
node_modules = VOYAGER_SRC / "voyager" / "env" / "mineflayer" / "node_modules"
|
| 177 |
-
marker = VOYAGER_ROOT / ".node-ready"
|
| 178 |
-
deadline = time.monotonic() + 900
|
| 179 |
-
while not self.stop_event.is_set() and (
|
| 180 |
-
not marker.exists() or not (node_modules / "mineflayer").exists()
|
| 181 |
-
):
|
| 182 |
-
if time.monotonic() > deadline:
|
| 183 |
-
raise RuntimeError("Voyager Mineflayer dependencies did not become ready")
|
| 184 |
-
self.stop_event.wait(2)
|
| 185 |
-
if self.stop_event.is_set():
|
| 186 |
-
raise RuntimeError("stopped")
|
| 187 |
-
env = os.environ.copy()
|
| 188 |
-
for name in (*KEY_NAMES, "GEMINI_API_KEY", "GOOGLE_API_KEY"):
|
| 189 |
-
env.pop(name, None)
|
| 190 |
-
env["NODE_PATH"] = str(node_modules)
|
| 191 |
-
return env
|
| 192 |
-
|
| 193 |
def _start_proxy(self, env: dict[str, str]) -> None:
|
| 194 |
entrypoint = Path(__file__).resolve().parent / "gemini_voyager_proxy.py"
|
| 195 |
PROXY_LOG_PATH.write_text("", encoding="utf-8")
|
|
@@ -201,7 +177,6 @@ class GeminiBotStack:
|
|
| 201 |
stderr=subprocess.STDOUT,
|
| 202 |
text=True,
|
| 203 |
bufsize=1,
|
| 204 |
-
start_new_session=(os.name == "posix"),
|
| 205 |
)
|
| 206 |
threading.Thread(
|
| 207 |
target=self._pump,
|
|
@@ -240,15 +215,6 @@ class GeminiBotStack:
|
|
| 240 |
"ANONYMIZED_TELEMETRY": "False",
|
| 241 |
"PYTHONPATH": str(VOYAGER_SRC),
|
| 242 |
})
|
| 243 |
-
if self.remote_mine:
|
| 244 |
-
from voyager_stack import BRAIN_BASE_URL, BRAIN_MODEL, BRAIN_AUX_MODEL
|
| 245 |
-
voyager_env.update({
|
| 246 |
-
"VOYAGER_OPENAI_BASE": BRAIN_BASE_URL,
|
| 247 |
-
"VOYAGER_BRAIN_PROFILE": "gemini-mine-qwen3-coder",
|
| 248 |
-
"VOYAGER_API_KEY": os.environ.get("VOYAGER_API_KEY", "local-voyager"),
|
| 249 |
-
"VOYAGER_MODEL": BRAIN_MODEL,
|
| 250 |
-
"VOYAGER_AUX_MODEL": BRAIN_AUX_MODEL,
|
| 251 |
-
})
|
| 252 |
LOG_PATH.write_text("", encoding="utf-8")
|
| 253 |
self.proc = subprocess.Popen(
|
| 254 |
[sys.executable, "-u", str(runner)],
|
|
@@ -258,7 +224,6 @@ class GeminiBotStack:
|
|
| 258 |
stderr=subprocess.STDOUT,
|
| 259 |
text=True,
|
| 260 |
bufsize=1,
|
| 261 |
-
start_new_session=(os.name == "posix"),
|
| 262 |
)
|
| 263 |
threading.Thread(
|
| 264 |
target=self._pump,
|
|
@@ -288,7 +253,7 @@ class GeminiBotStack:
|
|
| 288 |
if not ready_file.exists():
|
| 289 |
raise RuntimeError("Minecraft world did not finish loading")
|
| 290 |
|
| 291 |
-
if not
|
| 292 |
with self.lock:
|
| 293 |
self.state = "waiting-credentials"
|
| 294 |
self.error = "Set GEMINI_BOT_KEY_1 as a Space secret"
|
|
@@ -296,19 +261,16 @@ class GeminiBotStack:
|
|
| 296 |
|
| 297 |
with self.lock:
|
| 298 |
self.state = "preparing"
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
else:
|
| 302 |
-
cli, workspace = self._prepare()
|
| 303 |
-
env = self._child_env(cli, workspace)
|
| 304 |
backoff = 5
|
| 305 |
|
| 306 |
while not self.stop_event.is_set():
|
| 307 |
-
|
| 308 |
-
|
| 309 |
self._start_voyager(env)
|
| 310 |
time.sleep(3)
|
| 311 |
-
if self.proc.poll() is None and
|
| 312 |
with self.lock:
|
| 313 |
self.state = "running"
|
| 314 |
self.error = ""
|
|
@@ -316,7 +278,7 @@ class GeminiBotStack:
|
|
| 316 |
while (
|
| 317 |
not self.stop_event.is_set()
|
| 318 |
and self.proc.poll() is None
|
| 319 |
-
and
|
| 320 |
):
|
| 321 |
self.stop_event.wait(1)
|
| 322 |
|
|
@@ -325,8 +287,8 @@ class GeminiBotStack:
|
|
| 325 |
|
| 326 |
runner_rc = self.proc.returncode if self.proc else None
|
| 327 |
proxy_rc = self.proxy_proc.returncode if self.proxy_proc else None
|
| 328 |
-
|
| 329 |
-
|
| 330 |
self.restart_count += 1
|
| 331 |
with self.lock:
|
| 332 |
self.state = "restarting"
|
|
@@ -336,15 +298,15 @@ class GeminiBotStack:
|
|
| 336 |
)
|
| 337 |
if self.stop_event.wait(backoff):
|
| 338 |
break
|
| 339 |
-
backoff =
|
| 340 |
|
| 341 |
-
|
| 342 |
-
|
| 343 |
with self.lock:
|
| 344 |
self.state = "stopped"
|
| 345 |
except Exception as exc:
|
| 346 |
-
|
| 347 |
-
|
| 348 |
with self.lock:
|
| 349 |
self.state = "error"
|
| 350 |
self.error = str(exc)[:200]
|
|
@@ -385,8 +347,6 @@ class GeminiBotStack:
|
|
| 385 |
return {"ready": False}
|
| 386 |
|
| 387 |
def _proxy_status(self) -> dict[str, Any]:
|
| 388 |
-
if self.remote_mine:
|
| 389 |
-
return {"enabled": False, "reason": "all model roles use the Mine Space"}
|
| 390 |
try:
|
| 391 |
with urllib.request.urlopen(
|
| 392 |
f"http://127.0.0.1:{PROXY_PORT}/health", timeout=1.5
|
|
@@ -399,10 +359,7 @@ class GeminiBotStack:
|
|
| 399 |
def status(self) -> dict[str, Any]:
|
| 400 |
completed = _read_json(CKPT_DIR / "curriculum" / "completed_tasks.json")
|
| 401 |
failed = _read_json(CKPT_DIR / "curriculum" / "failed_tasks.json")
|
| 402 |
-
|
| 403 |
-
lines = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
|
| 404 |
-
except OSError:
|
| 405 |
-
lines = []
|
| 406 |
mineflayer = self._mineflayer_status()
|
| 407 |
learning = read_learning_state(CKPT_DIR)
|
| 408 |
proxy = self._proxy_status()
|
|
@@ -418,24 +375,20 @@ class GeminiBotStack:
|
|
| 418 |
return {
|
| 419 |
"state": self.state,
|
| 420 |
"error": self.error or None,
|
| 421 |
-
"running": runner_running and
|
| 422 |
"pid": proc.pid if runner_running else None,
|
| 423 |
"proxy_pid": proxy_proc.pid if proxy_running else None,
|
| 424 |
"restart_count": self.restart_count,
|
| 425 |
-
"last_exit": read_exit_report(VOYAGER_ROOT, "Gemini_Bot"),
|
| 426 |
"started_at": self.started_at,
|
| 427 |
"architecture": "Voyager",
|
| 428 |
"same_voyager_runtime_as_naldo": bool(engine and naldo_engine and engine.get("source_root") == naldo_engine.get("source_root") == str(VOYAGER_SRC.resolve()) and engine.get("upstream_commit") == naldo_engine.get("upstream_commit") == UPSTREAM_COMMIT),
|
| 429 |
"engine_runtime": engine,
|
| 430 |
"bot_username": "Gemini_Bot",
|
| 431 |
-
"
|
| 432 |
-
"
|
| 433 |
-
"
|
| 434 |
-
"
|
| 435 |
-
"
|
| 436 |
-
"brain_profile": "gemini-mine-qwen3-coder" if self.remote_mine else "gemini-cli",
|
| 437 |
-
"embedding_backend": "remote-mine-space",
|
| 438 |
-
"cli_version": None if self.remote_mine else CLI_VERSION,
|
| 439 |
"configured_key_count": sum(
|
| 440 |
bool(os.environ.get(name)) for name in KEY_NAMES
|
| 441 |
),
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
+
from voyager_stack import ROOT as VOYAGER_ROOT, SRC as VOYAGER_SRC, CKPT_DIR as NALDO_CKPT_DIR, VOYAGER, _wait_tcp, restart_backoff_after, tail_lines
|
| 16 |
+
from voyager_runtime_contract import UPSTREAM_COMMIT, read_attestation
|
| 17 |
from voyager_learning import read_learning_state
|
|
|
|
| 18 |
|
| 19 |
ROOT = Path(os.environ.get("GEMINI_BOT_RUNTIME_HOME", "/tmp/gemini-bot-runtime"))
|
| 20 |
PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def _terminate(proc: subprocess.Popen[str] | None, timeout: int = 15) -> None:
|
| 41 |
+
if not proc or proc.poll() is not None:
|
| 42 |
+
return
|
| 43 |
+
proc.terminate()
|
| 44 |
+
try:
|
| 45 |
+
proc.wait(timeout=timeout)
|
| 46 |
+
except subprocess.TimeoutExpired:
|
| 47 |
+
proc.kill()
|
| 48 |
+
proc.wait(timeout=5)
|
| 49 |
|
| 50 |
|
| 51 |
def _read_json(path: Path) -> list[str]:
|
|
|
|
| 58 |
|
| 59 |
class GeminiBotStack:
|
| 60 |
def __init__(self) -> None:
|
|
|
|
| 61 |
self.lock = threading.RLock()
|
| 62 |
self.worker: threading.Thread | None = None
|
| 63 |
self.proc: subprocess.Popen[str] | None = None
|
|
|
|
| 162 |
"GEMINI_BOT_SYSTEM_MD": str(workspace / ".gemini" / "system.md"),
|
| 163 |
"GEMINI_VOYAGER_MODEL": MODEL,
|
| 164 |
"GEMINI_VOYAGER_PROXY_PORT": str(PROXY_PORT),
|
| 165 |
+
"GEMINI_VOYAGER_EMBED_BASE": "http://127.0.0.1:7860/v1",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
})
|
| 167 |
return env
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
def _start_proxy(self, env: dict[str, str]) -> None:
|
| 170 |
entrypoint = Path(__file__).resolve().parent / "gemini_voyager_proxy.py"
|
| 171 |
PROXY_LOG_PATH.write_text("", encoding="utf-8")
|
|
|
|
| 177 |
stderr=subprocess.STDOUT,
|
| 178 |
text=True,
|
| 179 |
bufsize=1,
|
|
|
|
| 180 |
)
|
| 181 |
threading.Thread(
|
| 182 |
target=self._pump,
|
|
|
|
| 215 |
"ANONYMIZED_TELEMETRY": "False",
|
| 216 |
"PYTHONPATH": str(VOYAGER_SRC),
|
| 217 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
LOG_PATH.write_text("", encoding="utf-8")
|
| 219 |
self.proc = subprocess.Popen(
|
| 220 |
[sys.executable, "-u", str(runner)],
|
|
|
|
| 224 |
stderr=subprocess.STDOUT,
|
| 225 |
text=True,
|
| 226 |
bufsize=1,
|
|
|
|
| 227 |
)
|
| 228 |
threading.Thread(
|
| 229 |
target=self._pump,
|
|
|
|
| 253 |
if not ready_file.exists():
|
| 254 |
raise RuntimeError("Minecraft world did not finish loading")
|
| 255 |
|
| 256 |
+
if not any(os.environ.get(name) for name in KEY_NAMES):
|
| 257 |
with self.lock:
|
| 258 |
self.state = "waiting-credentials"
|
| 259 |
self.error = "Set GEMINI_BOT_KEY_1 as a Space secret"
|
|
|
|
| 261 |
|
| 262 |
with self.lock:
|
| 263 |
self.state = "preparing"
|
| 264 |
+
cli, workspace = self._prepare()
|
| 265 |
+
env = self._child_env(cli, workspace)
|
|
|
|
|
|
|
|
|
|
| 266 |
backoff = 5
|
| 267 |
|
| 268 |
while not self.stop_event.is_set():
|
| 269 |
+
run_started = time.monotonic()
|
| 270 |
+
self._start_proxy(env)
|
| 271 |
self._start_voyager(env)
|
| 272 |
time.sleep(3)
|
| 273 |
+
if self.proc.poll() is None and self.proxy_proc.poll() is None:
|
| 274 |
with self.lock:
|
| 275 |
self.state = "running"
|
| 276 |
self.error = ""
|
|
|
|
| 278 |
while (
|
| 279 |
not self.stop_event.is_set()
|
| 280 |
and self.proc.poll() is None
|
| 281 |
+
and self.proxy_proc.poll() is None
|
| 282 |
):
|
| 283 |
self.stop_event.wait(1)
|
| 284 |
|
|
|
|
| 287 |
|
| 288 |
runner_rc = self.proc.returncode if self.proc else None
|
| 289 |
proxy_rc = self.proxy_proc.returncode if self.proxy_proc else None
|
| 290 |
+
_terminate(self.proc)
|
| 291 |
+
_terminate(self.proxy_proc)
|
| 292 |
self.restart_count += 1
|
| 293 |
with self.lock:
|
| 294 |
self.state = "restarting"
|
|
|
|
| 298 |
)
|
| 299 |
if self.stop_event.wait(backoff):
|
| 300 |
break
|
| 301 |
+
backoff = restart_backoff_after(backoff, time.monotonic() - run_started)
|
| 302 |
|
| 303 |
+
_terminate(self.proc)
|
| 304 |
+
_terminate(self.proxy_proc)
|
| 305 |
with self.lock:
|
| 306 |
self.state = "stopped"
|
| 307 |
except Exception as exc:
|
| 308 |
+
_terminate(self.proc)
|
| 309 |
+
_terminate(self.proxy_proc)
|
| 310 |
with self.lock:
|
| 311 |
self.state = "error"
|
| 312 |
self.error = str(exc)[:200]
|
|
|
|
| 347 |
return {"ready": False}
|
| 348 |
|
| 349 |
def _proxy_status(self) -> dict[str, Any]:
|
|
|
|
|
|
|
| 350 |
try:
|
| 351 |
with urllib.request.urlopen(
|
| 352 |
f"http://127.0.0.1:{PROXY_PORT}/health", timeout=1.5
|
|
|
|
| 359 |
def status(self) -> dict[str, Any]:
|
| 360 |
completed = _read_json(CKPT_DIR / "curriculum" / "completed_tasks.json")
|
| 361 |
failed = _read_json(CKPT_DIR / "curriculum" / "failed_tasks.json")
|
| 362 |
+
lines = tail_lines(LOG_PATH)
|
|
|
|
|
|
|
|
|
|
| 363 |
mineflayer = self._mineflayer_status()
|
| 364 |
learning = read_learning_state(CKPT_DIR)
|
| 365 |
proxy = self._proxy_status()
|
|
|
|
| 375 |
return {
|
| 376 |
"state": self.state,
|
| 377 |
"error": self.error or None,
|
| 378 |
+
"running": runner_running and proxy_running,
|
| 379 |
"pid": proc.pid if runner_running else None,
|
| 380 |
"proxy_pid": proxy_proc.pid if proxy_running else None,
|
| 381 |
"restart_count": self.restart_count,
|
|
|
|
| 382 |
"started_at": self.started_at,
|
| 383 |
"architecture": "Voyager",
|
| 384 |
"same_voyager_runtime_as_naldo": bool(engine and naldo_engine and engine.get("source_root") == naldo_engine.get("source_root") == str(VOYAGER_SRC.resolve()) and engine.get("upstream_commit") == naldo_engine.get("upstream_commit") == UPSTREAM_COMMIT),
|
| 385 |
"engine_runtime": engine,
|
| 386 |
"bot_username": "Gemini_Bot",
|
| 387 |
+
"model": MODEL,
|
| 388 |
+
"decision_brain": "gemini-cli-only",
|
| 389 |
+
"brain_profile": "gemini-cli",
|
| 390 |
+
"embedding_backend": "local-embeddings-only",
|
| 391 |
+
"cli_version": CLI_VERSION,
|
|
|
|
|
|
|
|
|
|
| 392 |
"configured_key_count": sum(
|
| 393 |
bool(os.environ.get(name)) for name in KEY_NAMES
|
| 394 |
),
|
minecraft_stack.py
CHANGED
|
@@ -1,221 +1,226 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
import hashlib
|
| 4 |
import uuid
|
| 5 |
import json
|
| 6 |
import datetime
|
| 7 |
-
import os
|
| 8 |
-
import re
|
| 9 |
-
import shutil
|
| 10 |
-
import socket
|
| 11 |
-
import subprocess
|
| 12 |
-
import tarfile
|
| 13 |
-
import threading
|
| 14 |
-
import tempfile
|
| 15 |
-
import time
|
| 16 |
-
import urllib.request
|
| 17 |
-
from pathlib import Path
|
| 18 |
-
from typing import Any
|
| 19 |
-
|
| 20 |
import yaml
|
| 21 |
-
|
| 22 |
-
BASE = Path(os.environ.get("MINECRAFT_HOME", "/tmp/minecraft-stack"))
|
| 23 |
-
PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
|
| 24 |
-
JAVA_DIR = BASE / "java"
|
| 25 |
-
VIA_DIR = BASE / "viaproxy"
|
| 26 |
-
# Microsoft/Xbox OAuth cache must survive Space rebuilds/restarts. The HF Bucket
|
| 27 |
-
# mounted at /persist is private and read-write; nether2rak refreshes token.json
|
| 28 |
-
# in-place, so using this directory preserves both initial login and token rotation.
|
| 29 |
-
RELAY_DIR = PERSIST_ROOT / "relay"
|
| 30 |
-
WORLD_DIR = PERSIST_ROOT / "world-
|
| 31 |
-
# Generator settings are only read when the world is created, so replacing the map
|
| 32 |
-
# means pointing at a new directory. The previous world is kept so the human
|
| 33 |
-
# player's Bedrock session can be carried over instead of being reset.
|
| 34 |
-
PREVIOUS_WORLD_DIR = PERSIST_ROOT / "world-
|
| 35 |
-
WORLD_PROFILE = "minecraft:flat"
|
| 36 |
-
WORLD_SURFACE_Y = 64
|
| 37 |
-
# Resource-rich farming map, bottom to top. Two constraints fix this shape:
|
| 38 |
-
# 1.18+ superflat stacks the layers upward from the -64 world floor, and Voyager's
|
| 39 |
-
# placement, navigation and prompts are all pinned to a walkable surface at Y=64,
|
| 40 |
-
# so the heights below must total exactly WORLD_FLOOR + WORLD_SURFACE_Y.
|
| 41 |
-
# Every ore is duplicated: a shallow band 6-24 blocks under the topsoil that a
|
| 42 |
-
# mining bot reaches from the surface, and a deepslate band at the bottom of the
|
| 43 |
-
# column. Plains with no lakes and no features keeps the surface predictable for
|
| 44 |
-
# the pathfinder and for players building on it.
|
| 45 |
-
WORLD_FLAT_LAYERS = (
|
| 46 |
-
("minecraft:bedrock", 1),
|
| 47 |
-
("minecraft:deepslate", 6),
|
| 48 |
-
("minecraft:deepslate_coal_ore", 3),
|
| 49 |
-
("minecraft:deepslate_copper_ore", 3),
|
| 50 |
-
("minecraft:deepslate_iron_ore", 3),
|
| 51 |
-
("minecraft:deepslate_gold_ore", 2),
|
| 52 |
-
("minecraft:deepslate_redstone_ore", 2),
|
| 53 |
-
("minecraft:deepslate_diamond_ore", 1),
|
| 54 |
-
("minecraft:deepslate_lapis_ore", 1),
|
| 55 |
-
("minecraft:deepslate_emerald_ore", 1),
|
| 56 |
-
("minecraft:stone", 81),
|
| 57 |
-
("minecraft:coal_ore", 4),
|
| 58 |
-
("minecraft:copper_ore", 4),
|
| 59 |
-
("minecraft:iron_ore", 4),
|
| 60 |
-
("minecraft:gold_ore", 2),
|
| 61 |
-
("minecraft:redstone_ore", 2),
|
| 62 |
-
("minecraft:diamond_ore", 1),
|
| 63 |
-
("minecraft:lapis_ore", 1),
|
| 64 |
-
("minecraft:emerald_ore", 1),
|
| 65 |
-
("minecraft:gravel", 1),
|
| 66 |
-
("minecraft:dirt", 3),
|
| 67 |
-
("minecraft:grass_block", 1),
|
| 68 |
-
)
|
| 69 |
-
WORLD_FLOOR_Y = -64
|
| 70 |
-
if sum(height for _, height in WORLD_FLAT_LAYERS) != WORLD_SURFACE_Y - WORLD_FLOOR_Y:
|
| 71 |
-
raise RuntimeError(
|
| 72 |
-
"World layers must total the distance from the world floor to WORLD_SURFACE_Y"
|
| 73 |
-
)
|
| 74 |
-
FLAT_GENERATOR_SETTINGS = json.dumps(
|
| 75 |
-
{
|
| 76 |
-
"biome": "minecraft:plains",
|
| 77 |
-
"layers": [{"block": block, "height": height} for block, height in WORLD_FLAT_LAYERS],
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
# lakes - only controls lava lakes, so it stays off.
|
| 84 |
-
# structure_overrides - omitted on purpose: the documented default is
|
| 85 |
-
# "all the structure sets". An empty list, which is
|
| 86 |
-
# what this used to pass, disables every structure.
|
| 87 |
-
"lakes": False,
|
| 88 |
-
"features": True,
|
| 89 |
-
},
|
| 90 |
-
separators=(",", ":"),
|
| 91 |
)
|
| 92 |
BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot")
|
| 93 |
ADMIN_USERNAME = "IffyBinkie6134"
|
| 94 |
MAX_PLAYERS = 100
|
| 95 |
-
TOOLS_DIR = BASE / "tools"
|
| 96 |
-
BIN_DIR = BASE / "bin"
|
| 97 |
-
LOG_PATH = BASE / "stack.log"
|
| 98 |
-
READY_FILE = BASE / "minecraft.ready"
|
| 99 |
-
|
| 100 |
-
FABRIC_INSTALLER_URL = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/0.11.2/fabric-installer-0.11.2.jar"
|
| 101 |
-
FABRIC_MC_VERSION = "1.19"
|
| 102 |
-
FABRIC_LOADER_VERSION = "0.14.18"
|
| 103 |
-
|
| 104 |
-
# Official Voyager Fabric 1.19 server-side mod stack.
|
| 105 |
-
FABRIC_API_URL = "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0%2B1.19/fabric-api-0.58.0%2B1.19.jar"
|
| 106 |
-
FABRIC_API_SHA256 = "0a3d2dba1ed45b002483ae7c08e2c0bcfcb8d2b166801a6c29d8ea64dba64370"
|
| 107 |
-
COMPLETE_CONFIG_URL = "https://cdn.modrinth.com/data/GtqG8z1h/versions/2.0.0/completeconfig-2.0.0.jar"
|
| 108 |
-
COMPLETE_CONFIG_SHA256 = "d34a3e167bf7942348d3f8c650fcefead6284878b39277ba43282ef69fa2d566"
|
| 109 |
-
SERVER_PAUSE_URL = "https://mediafilez.forgecdn.net/files/3822/586/Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar"
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
def _patch_scalar(text: str, key: str, value: str) -> str:
|
| 216 |
-
pattern = rf"(?m)^(\s*{re.escape(key)}:\s*).*$"
|
| 217 |
-
if re.search(pattern, text):
|
| 218 |
-
return re.sub(pattern, lambda m: m.group(1) + value, text, count=1)
|
| 219 |
return text + f"\n{key}: {value}\n"
|
| 220 |
|
| 221 |
|
|
@@ -272,85 +277,43 @@ def _relay_diagnostics(lines: list[str]) -> dict[str, Any]:
|
|
| 272 |
}
|
| 273 |
|
| 274 |
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
"player
|
| 311 |
-
|
| 312 |
-
"rtt_ms": int(payload.get("ms", 0)),
|
| 313 |
-
"measured_by": "nether2rak client leg + backend leg, rolling average",
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
def _offline_player_uuid(username: str) -> str:
|
| 318 |
-
return str(uuid.UUID(bytes=hashlib.md5(
|
| 319 |
-
f"OfflinePlayer:{username}".encode("utf-8")
|
| 320 |
-
).digest(), version=3))
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
def _carry_player_saves(previous: Path, current: Path) -> None:
|
| 324 |
-
"""Copy human player saves into a newly generated world.
|
| 325 |
-
|
| 326 |
-
The Xbox OAuth token lives in RELAY_DIR and is never touched here, so a map
|
| 327 |
-
change must not cost a player their session. Player saves are copied instead
|
| 328 |
-
of the world itself: they only store a player's position, inventory and
|
| 329 |
-
gamemode, which are valid in any world of the same dimension.
|
| 330 |
-
|
| 331 |
-
The bot is deliberately excluded. A full bot inventory is what pins the
|
| 332 |
-
official CurriculumAgent to its hardcoded full-inventory branch, so a fresh
|
| 333 |
-
world has to start the bot from an empty save.
|
| 334 |
-
"""
|
| 335 |
-
source = previous / "playerdata"
|
| 336 |
-
if not source.is_dir():
|
| 337 |
-
return
|
| 338 |
-
destination = current / "playerdata"
|
| 339 |
-
destination.mkdir(parents=True, exist_ok=True)
|
| 340 |
-
bot_save = f"{_offline_player_uuid(BOT_USERNAME)}.dat"
|
| 341 |
-
carried = []
|
| 342 |
-
for save in sorted(source.glob("*.dat")):
|
| 343 |
-
if save.name == bot_save:
|
| 344 |
-
continue
|
| 345 |
-
try:
|
| 346 |
-
shutil.copy2(save, destination / save.name)
|
| 347 |
-
except OSError as exc:
|
| 348 |
-
_log(f"could not carry player save {save.name}: {exc}")
|
| 349 |
-
continue
|
| 350 |
-
carried.append(save.name)
|
| 351 |
-
if carried:
|
| 352 |
-
_log(f"Carried {len(carried)} player save(s) into the new world: {', '.join(carried)}")
|
| 353 |
-
else:
|
| 354 |
_log("No human player saves to carry into the new world")
|
| 355 |
|
| 356 |
|
|
@@ -362,31 +325,12 @@ def _server_performance(lines: list[str], now: datetime.datetime | None = None)
|
|
| 362 |
tick_lag = [line for line in recent if "Can't keep up!" in line]
|
| 363 |
delays = [int(match.group(1)) for line in tick_lag
|
| 364 |
if (match := re.search(r"Running (\d+)ms or \d+ ticks behind", line))]
|
| 365 |
-
movement = [line for line in recent
|
| 366 |
-
if "moved too quickly" in line or "moved wrongly" in line]
|
| 367 |
-
# The count alone cannot say whether this is one entity joining or the whole
|
| 368 |
-
# server degrading. Measured: the counter went 0 -> 107 in under a minute and
|
| 369 |
-
# then stopped, which is a burst, so the entity behind it has to be visible.
|
| 370 |
-
# Deduplicate on the message, not the raw line: a burst carries a distinct
|
| 371 |
-
# timestamp on every entry, so comparing raw lines would fill the payload with
|
| 372 |
-
# five copies of one problem instead of showing five distinct ones.
|
| 373 |
-
samples: list[str] = []
|
| 374 |
-
seen: set[str] = set()
|
| 375 |
-
for line in movement:
|
| 376 |
-
trimmed = line.strip()[:200]
|
| 377 |
-
key = re.sub(r"^\[[^\]]*\]\s*", "", trimmed)
|
| 378 |
-
if key in seen:
|
| 379 |
-
continue
|
| 380 |
-
seen.add(key)
|
| 381 |
-
samples.append(trimmed)
|
| 382 |
-
if len(samples) >= 5:
|
| 383 |
-
break
|
| 384 |
return {
|
| 385 |
"window_seconds": 300,
|
| 386 |
"tick_lag_events": len(tick_lag),
|
| 387 |
"max_reported_tick_delay_ms": max(delays, default=None),
|
| 388 |
-
"movement_warnings":
|
| 389 |
-
|
| 390 |
"last_tick_lag_at": tick_lag[-1][1:20] if tick_lag else None,
|
| 391 |
}
|
| 392 |
|
|
@@ -427,163 +371,169 @@ def _ensure_admin_operator(path: Path, username: str) -> None:
|
|
| 427 |
)]
|
| 428 |
preserved.append(entry)
|
| 429 |
path.write_text(json.dumps(preserved, indent=2) + "\n", encoding="utf-8")
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
class MinecraftStack:
|
| 433 |
-
def __init__(self) -> None:
|
| 434 |
-
self.lock = threading.RLock()
|
| 435 |
-
self.worker: threading.Thread | None = None
|
| 436 |
-
self.processes: dict[str, subprocess.Popen[str]] = {}
|
| 437 |
-
self.state = "stopped"
|
| 438 |
-
self.error = ""
|
| 439 |
-
self.started_at: float | None = None
|
| 440 |
-
self.minecraft_ready = threading.Event()
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
try:
|
| 482 |
-
proc.
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
for filename, url, sha256 in mods:
|
| 588 |
_download(url, mods_dir / filename, sha256)
|
| 589 |
|
|
@@ -597,279 +547,280 @@ class MinecraftStack:
|
|
| 597 |
_log("Removed faulty Better Respawn 1.19 mod from Fabric mods directory")
|
| 598 |
_log(
|
| 599 |
"Official Voyager Fabric mods ready: Fabric API 0.58.0+1.19, "
|
| 600 |
-
"CompleteConfig 2.0.0, Multiplayer Server Pause 1.1.0
|
| 601 |
-
"TooFast 1.0.0 (local, removes the Bedrock movement corrections). "
|
| 602 |
"Better Respawn 1.19-2.0.0 is disabled due to its player-save NPE. Mod Menu is client-only and is "
|
| 603 |
-
"intentionally omitted on this headless dedicated server."
|
| 604 |
-
)
|
| 605 |
-
|
| 606 |
-
def _ensure_fabric(self) -> Path:
|
| 607 |
-
java = self._ensure_minecraft_java()
|
| 608 |
-
installer = _download(FABRIC_INSTALLER_URL, TOOLS_DIR / "fabric-installer-0.11.2.jar")
|
| 609 |
-
JAVA_DIR.mkdir(parents=True, exist_ok=True)
|
| 610 |
-
launcher = JAVA_DIR / "fabric-server-launch.jar"
|
| 611 |
-
if not launcher.exists():
|
| 612 |
-
_run_capture(
|
| 613 |
-
[
|
| 614 |
-
str(java), "-jar", str(installer), "server",
|
| 615 |
-
"-mcversion", FABRIC_MC_VERSION,
|
| 616 |
-
"-loader", FABRIC_LOADER_VERSION,
|
| 617 |
-
"-downloadMinecraft",
|
| 618 |
-
"-dir", str(JAVA_DIR),
|
| 619 |
-
],
|
| 620 |
-
JAVA_DIR,
|
| 621 |
-
timeout=300,
|
| 622 |
-
)
|
| 623 |
-
self._ensure_voyager_mods()
|
| 624 |
-
(JAVA_DIR / "eula.txt").write_text("eula=true\n", encoding="utf-8")
|
| 625 |
-
props = {
|
| 626 |
-
"server-ip": "127.0.0.1",
|
| 627 |
-
"server-port": "25565",
|
| 628 |
-
"online-mode": "false",
|
| 629 |
-
"enforce-secure-profile": "false",
|
| 630 |
-
"enable-command-block": "true",
|
| 631 |
-
"spawn-protection": "0",
|
| 632 |
-
"allow-flight": "true",
|
| 633 |
-
"gamemode": "creative",
|
| 634 |
-
"force-gamemode": "true",
|
| 635 |
"difficulty": "peaceful",
|
| 636 |
"max-players": str(MAX_PLAYERS),
|
| 637 |
"op-permission-level": "4",
|
| 638 |
-
"view-distance": "8",
|
| 639 |
-
"simulation-distance": "6",
|
| 640 |
-
"level-name": "world",
|
| 641 |
"level-type": "minecraft:flat",
|
| 642 |
"generator-settings": FLAT_GENERATOR_SETTINGS,
|
| 643 |
-
"generate-structures": "
|
| 644 |
-
"motd": "Voyager AI + Bedrock Friends",
|
| 645 |
-
}
|
| 646 |
(JAVA_DIR / "server.properties").write_text(
|
| 647 |
"\n".join(f"{k}={v}" for k, v in props.items()) + "\n",
|
| 648 |
encoding="utf-8",
|
| 649 |
)
|
| 650 |
_ensure_admin_operator(JAVA_DIR / "ops.json", ADMIN_USERNAME)
|
| 651 |
self._ensure_persistent_world()
|
| 652 |
-
return launcher
|
| 653 |
-
|
| 654 |
-
def
|
| 655 |
-
launcher = self._ensure_fabric()
|
| 656 |
-
self.minecraft_ready.clear()
|
| 657 |
-
READY_FILE.unlink(missing_ok=True)
|
| 658 |
-
proc = self._spawn(
|
| 659 |
-
"minecraft",
|
| 660 |
-
[str(self._ensure_minecraft_java()), "-Xms1G", "-Xmx8G",
|
| 661 |
-
"--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED",
|
| 662 |
-
f"-javaagent:{Path(__file__).resolve().parent / 'diagnostics/player-save-diagnostics.jar'}",
|
| 663 |
-
"-jar", str(launcher), "nogui"],
|
| 664 |
-
JAVA_DIR,
|
| 665 |
-
)
|
| 666 |
-
if not _wait_tcp("127.0.0.1", 25565, 120):
|
| 667 |
-
raise RuntimeError("Minecraft Java 1.19 did not open 127.0.0.1:25565")
|
| 668 |
-
if not self.minecraft_ready.wait(timeout=180):
|
| 669 |
-
raise RuntimeError("Minecraft Java opened 25565 but world did not finish loading")
|
| 670 |
-
if proc.stdin:
|
| 671 |
-
for cmd in (
|
| 672 |
-
f"op {BOT_USERNAME}",
|
| 673 |
-
f"gamemode creative {BOT_USERNAME}",
|
| 674 |
-
"gamerule keepInventory true",
|
| 675 |
-
"gamerule doDaylightCycle false",
|
| 676 |
-
# The cycle is frozen, so set the time once: the world used to be
|
| 677 |
-
# frozen at night, which is what the bot reported in every
|
| 678 |
-
# observation and what a player saw on join.
|
| 679 |
-
"time set day",
|
| 680 |
-
"difficulty peaceful",
|
| 681 |
-
):
|
| 682 |
-
proc.stdin.write(cmd + "\n")
|
| 683 |
-
proc.stdin.flush()
|
| 684 |
-
READY_FILE.write_text("ready\n", encoding="utf-8")
|
| 685 |
-
_log("Minecraft Java 1.19/Fabric fully ready on 127.0.0.1:25565")
|
| 686 |
-
|
| 687 |
-
def _ensure_viaproxy(self) -> tuple[Path, Path]:
|
| 688 |
-
jar = _download(VIAPROXY_URL, VIA_DIR / "ViaProxy-3.4.13.jar", VIAPROXY_SHA256)
|
| 689 |
-
plugin_dir = VIA_DIR / "plugins"
|
| 690 |
-
plugin_dir.mkdir(parents=True, exist_ok=True)
|
| 691 |
-
_download(GEYSER_URL, plugin_dir / "Geyser-ViaProxy.jar", GEYSER_SHA256)
|
| 692 |
-
cfg = VIA_DIR / "viaproxy.yml"
|
| 693 |
-
if not cfg.exists():
|
| 694 |
-
proc = subprocess.Popen(
|
| 695 |
-
[str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)],
|
| 696 |
-
cwd=str(VIA_DIR),
|
| 697 |
-
stdout=subprocess.PIPE,
|
| 698 |
-
stderr=subprocess.STDOUT,
|
| 699 |
-
text=True,
|
| 700 |
-
)
|
| 701 |
-
deadline = time.time() + 25
|
| 702 |
-
while time.time() < deadline and not cfg.exists() and proc.poll() is None:
|
| 703 |
-
time.sleep(1)
|
| 704 |
-
if proc.poll() is None:
|
| 705 |
-
proc.terminate()
|
| 706 |
-
try:
|
| 707 |
-
proc.wait(timeout=8)
|
| 708 |
-
except Exception:
|
| 709 |
-
proc.kill()
|
| 710 |
-
if not cfg.exists():
|
| 711 |
-
raise RuntimeError("ViaProxy did not generate viaproxy.yml")
|
| 712 |
-
text = cfg.read_text(encoding="utf-8", errors="replace")
|
| 713 |
-
text = _patch_scalar(text, "bind-address", "127.0.0.1:25568")
|
| 714 |
-
text = _patch_scalar(text, "target-address", "127.0.0.1:25565")
|
| 715 |
-
text = _patch_scalar(text, "proxy-online-mode", "false")
|
| 716 |
-
text = _patch_scalar(text, "auth-method", "none")
|
| 717 |
-
cfg.write_text(text, encoding="utf-8")
|
| 718 |
-
return jar, cfg
|
| 719 |
-
|
| 720 |
-
def _patch_geyser(self, path: Path) -> None:
|
| 721 |
-
data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
| 722 |
-
bedrock = data.setdefault("bedrock", {})
|
| 723 |
-
bedrock["address"] = "127.0.0.1"
|
| 724 |
-
bedrock["port"] = 19132
|
| 725 |
-
bedrock["clone-remote-port"] = False
|
| 726 |
-
java = data.setdefault("java", {})
|
| 727 |
-
java["auth-type"] = "offline"
|
| 728 |
-
advanced = data.setdefault("advanced", {})
|
| 729 |
-
adv_bedrock = advanced.setdefault("bedrock", {})
|
| 730 |
-
adv_bedrock["broadcast-port"] = 19132
|
| 731 |
-
adv_bedrock["validate-bedrock-login"] = False
|
| 732 |
-
data["debug-mode"] = False
|
| 733 |
-
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
| 734 |
-
_log("Geyser configured loopback-only; Xbox/NetherNet relay remains the auth boundary")
|
| 735 |
-
|
| 736 |
-
def _start_viaproxy(self) -> None:
|
| 737 |
-
jar, cfg = self._ensure_viaproxy()
|
| 738 |
-
geyser_cfg = VIA_DIR / "plugins" / "Geyser" / "config.yml"
|
| 739 |
-
if not geyser_cfg.exists():
|
| 740 |
-
proc = self._spawn("viaproxy-bootstrap", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
|
| 741 |
-
deadline = time.time() + 35
|
| 742 |
-
while time.time() < deadline and not geyser_cfg.exists() and proc.poll() is None:
|
| 743 |
-
time.sleep(1)
|
| 744 |
-
self._stop_process("viaproxy-bootstrap")
|
| 745 |
-
if not geyser_cfg.exists():
|
| 746 |
-
raise RuntimeError("Geyser-ViaProxy did not generate plugins/Geyser/config.yml")
|
| 747 |
-
self._patch_geyser(geyser_cfg)
|
| 748 |
-
proc = self._spawn("viaproxy", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
|
| 749 |
-
time.sleep(12)
|
| 750 |
-
if proc.poll() is not None:
|
| 751 |
-
raise RuntimeError(f"ViaProxy/Geyser exited rc={proc.returncode}")
|
| 752 |
-
_log("ViaProxy + Geyser running; Bedrock backend is 127.0.0.1:19132")
|
| 753 |
-
|
| 754 |
-
def _ensure_go(self) -> Path:
|
| 755 |
-
go = TOOLS_DIR / "go" / "bin" / "go"
|
| 756 |
-
if go.exists():
|
| 757 |
-
return go
|
| 758 |
-
archive = _download(GO_URL, TOOLS_DIR / "go1.25.14.linux-amd64.tar.gz", GO_SHA256)
|
| 759 |
-
target = TOOLS_DIR / "go"
|
| 760 |
-
if target.exists():
|
| 761 |
-
shutil.rmtree(target)
|
| 762 |
-
with tarfile.open(archive, "r:gz") as tf:
|
| 763 |
-
tf.extractall(TOOLS_DIR)
|
| 764 |
-
if not go.exists():
|
| 765 |
-
raise RuntimeError("Go toolchain extraction failed")
|
| 766 |
-
return go
|
| 767 |
-
|
| 768 |
-
def _ensure_nether2rak(self) -> Path:
|
| 769 |
-
binary = BIN_DIR / "nether2rak"
|
| 770 |
-
if binary.exists():
|
| 771 |
-
return binary
|
| 772 |
-
go = self._ensure_go()
|
| 773 |
-
src = Path("/app/third_party/nether2rak")
|
| 774 |
-
if not src.exists():
|
| 775 |
-
src = Path(__file__).resolve().parent / "third_party" / "nether2rak"
|
| 776 |
-
if not src.exists():
|
| 777 |
-
raise RuntimeError("Vendored nether2rak source is missing")
|
| 778 |
-
env = os.environ.copy()
|
| 779 |
-
env.update(
|
| 780 |
-
{
|
| 781 |
-
"PATH": f"{go.parent}:{env.get('PATH', '')}",
|
| 782 |
-
"GOTOOLCHAIN": "local",
|
| 783 |
-
"GOCACHE": str(BASE / "go-cache"),
|
| 784 |
-
"GOMODCACHE": str(BASE / "go-mod-cache"),
|
| 785 |
-
"CGO_ENABLED": "0",
|
| 786 |
-
}
|
| 787 |
-
)
|
| 788 |
-
_run_capture([str(go), "build", "-trimpath", "-o", str(binary), "."], src, timeout=900, env=env)
|
| 789 |
-
binary.chmod(0o755)
|
| 790 |
-
return binary
|
| 791 |
-
|
| 792 |
-
def _write_relay_config(self) -> Path:
|
| 793 |
-
cfg = {
|
| 794 |
-
"backend_transport": "raknet",
|
| 795 |
-
"geyser_address": "127.0.0.1:19132",
|
| 796 |
-
"nethernet_backend_address": "http://127.0.0.1:19134",
|
| 797 |
-
"host_name": HOST_NAME,
|
| 798 |
-
"world_name": WORLD_NAME,
|
| 799 |
-
"max_players": MAX_PLAYERS,
|
| 800 |
-
"fake_player_count": 0,
|
| 801 |
-
"protocol": BEDROCK_PROTOCOL,
|
| 802 |
-
"version": BEDROCK_VERSION,
|
| 803 |
-
"update_interval_seconds": 30,
|
| 804 |
-
"allowed_xuids": [],
|
| 805 |
-
"compression": "snappy",
|
| 806 |
-
"compression_threshold": 256,
|
| 807 |
-
"fix_native_bds_persistence": False,
|
| 808 |
-
"disable_client_encryption": False,
|
| 809 |
-
"relay_direct_ip": False,
|
| 810 |
-
"relay_direct_ip_listen_address": ":19134",
|
| 811 |
-
"invite_port": 7782,
|
| 812 |
-
"ping_port": 7777,
|
| 813 |
-
"pprof_port": 6060,
|
| 814 |
-
"extra_broadcasts": [],
|
| 815 |
-
}
|
| 816 |
-
path = RELAY_DIR / "config.json"
|
| 817 |
-
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
| 818 |
-
return path
|
| 819 |
-
|
| 820 |
-
def _start_relay(self) -> None:
|
| 821 |
-
binary = self._ensure_nether2rak()
|
| 822 |
-
self._write_relay_config()
|
| 823 |
-
proc = self._spawn("friends", [str(binary)], RELAY_DIR)
|
| 824 |
-
time.sleep(4)
|
| 825 |
-
if proc.poll() is not None:
|
| 826 |
-
raise RuntimeError(f"Xbox Friends relay exited rc={proc.returncode}")
|
| 827 |
-
_log("Xbox Friends relay started; Microsoft device-code login may be required")
|
| 828 |
-
|
| 829 |
-
def _boot(self) -> None:
|
| 830 |
with self.lock:
|
| 831 |
-
self.
|
| 832 |
-
self.
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 873 |
def status(self) -> dict[str, Any]:
|
| 874 |
with self.lock:
|
| 875 |
processes = {
|
|
@@ -879,14 +830,10 @@ class MinecraftStack:
|
|
| 879 |
for name, process in processes.items():
|
| 880 |
if process["running"]:
|
| 881 |
process["rss_gib"] = _process_rss_gib(process["pid"])
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
server_lines = [line for line in all_lines[boot:] if "minecraft>" in line]
|
| 887 |
-
except OSError:
|
| 888 |
-
all_lines = []
|
| 889 |
-
server_lines = []
|
| 890 |
log = "\n".join(all_lines[-120:]) if all_lines else "Sem logs do Minecraft ainda."
|
| 891 |
relay_health = _relay_diagnostics(all_lines[boot:] if all_lines else [])
|
| 892 |
access_state = _access_state(self.state, self.started_at, relay_health)
|
|
@@ -921,57 +868,55 @@ class MinecraftStack:
|
|
| 921 |
player_stat = None
|
| 922 |
player_save = {
|
| 923 |
"better_respawn_jar_present": (JAVA_DIR / "mods" / "better-respawn-fabric-1.19-2.0.0.jar").exists(),
|
| 924 |
-
"failed_saves_since_boot":
|
| 925 |
-
"respawn_npe_since_boot":
|
| 926 |
-
for line in server_lines),
|
| 927 |
"player_file_exists": player_stat is not None,
|
| 928 |
"player_file_size": player_stat.st_size if player_stat else None,
|
| 929 |
"player_file_mtime": player_stat.st_mtime if player_stat else None,
|
| 930 |
}
|
| 931 |
-
login_url = None
|
| 932 |
-
device_code = None
|
| 933 |
-
url_match = re.findall(r"https://(?:www\.)?microsoft\.com/link", log, flags=re.I)
|
| 934 |
-
if url_match:
|
| 935 |
-
login_url = url_match[-1]
|
| 936 |
-
for pattern in (
|
| 937 |
-
r"(?:code|c[oó]digo)\s*[:=]\s*([A-Z0-9-]{6,16})",
|
| 938 |
-
r"enter\s+([A-Z0-9-]{6,16})\s+(?:to|at)",
|
| 939 |
-
r"using\s+the\s+code\s+([A-Z0-9-]{6,16})",
|
| 940 |
-
):
|
| 941 |
-
found = re.findall(pattern, log, flags=re.I)
|
| 942 |
-
if found:
|
| 943 |
-
device_code = found[-1].upper()
|
| 944 |
-
break
|
| 945 |
-
return {
|
| 946 |
-
"state": self.state,
|
| 947 |
-
"error": self.error or None,
|
| 948 |
-
"started_at": self.started_at,
|
| 949 |
-
"java_server": "127.0.0.1:25565",
|
| 950 |
-
"bedrock_backend": "127.0.0.1:19132",
|
| 951 |
-
"access": "Xbox/Microsoft Friends via NetherNet; no public UDP tunnel",
|
| 952 |
-
"auth_persistence": str(RELAY_DIR / "token.json"),
|
| 953 |
-
"auth_token_cached": (RELAY_DIR / "token.json").exists(),
|
| 954 |
-
"persistent_root": str(PERSIST_ROOT),
|
| 955 |
"world_persistence": str(WORLD_DIR),
|
| 956 |
"world_profile": WORLD_PROFILE,
|
| 957 |
"world_surface_y": WORLD_SURFACE_Y,
|
| 958 |
"player_save": player_save,
|
| 959 |
"relay_health": relay_health,
|
| 960 |
-
"relay_latency": _relay_player_latency(all_lines[boot:] if all_lines else []),
|
| 961 |
"access_state": access_state,
|
| 962 |
"performance": performance,
|
| 963 |
"player_limit": {"java": java_player_limit, "xbox_friends": xbox_player_limit},
|
| 964 |
"admin_operator": admin_operator,
|
| 965 |
"admin_player_file_exists": admin_player_file_exists,
|
| 966 |
-
"bot_username": BOT_USERNAME,
|
| 967 |
-
"bot_display_name": "Naldo Bot",
|
| 968 |
-
"bedrock_protocol": BEDROCK_PROTOCOL,
|
| 969 |
-
"bedrock_version": BEDROCK_VERSION,
|
| 970 |
-
"microsoft_login_url": login_url,
|
| 971 |
-
"microsoft_device_code": device_code,
|
| 972 |
-
"processes": processes,
|
| 973 |
-
"log_tail": log,
|
| 974 |
-
}
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
STACK = MinecraftStack()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
import hashlib
|
| 4 |
import uuid
|
| 5 |
import json
|
| 6 |
import datetime
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import shutil
|
| 10 |
+
import socket
|
| 11 |
+
import subprocess
|
| 12 |
+
import tarfile
|
| 13 |
+
import threading
|
| 14 |
+
import tempfile
|
| 15 |
+
import time
|
| 16 |
+
import urllib.request
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
import yaml
|
| 21 |
+
|
| 22 |
+
BASE = Path(os.environ.get("MINECRAFT_HOME", "/tmp/minecraft-stack"))
|
| 23 |
+
PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
|
| 24 |
+
JAVA_DIR = BASE / "java"
|
| 25 |
+
VIA_DIR = BASE / "viaproxy"
|
| 26 |
+
# Microsoft/Xbox OAuth cache must survive Space rebuilds/restarts. The HF Bucket
|
| 27 |
+
# mounted at /persist is private and read-write; nether2rak refreshes token.json
|
| 28 |
+
# in-place, so using this directory preserves both initial login and token rotation.
|
| 29 |
+
RELAY_DIR = PERSIST_ROOT / "relay"
|
| 30 |
+
WORLD_DIR = PERSIST_ROOT / "world-20260928-ores-y64-v1"
|
| 31 |
+
# Generator settings are only read when the world is created, so replacing the map
|
| 32 |
+
# means pointing at a new directory. The previous world is kept so the human
|
| 33 |
+
# player's Bedrock session can be carried over instead of being reset.
|
| 34 |
+
PREVIOUS_WORLD_DIR = PERSIST_ROOT / "world-20260927-flat-y64-v1"
|
| 35 |
+
WORLD_PROFILE = "minecraft:flat"
|
| 36 |
+
WORLD_SURFACE_Y = 64
|
| 37 |
+
# Resource-rich farming map, bottom to top. Two constraints fix this shape:
|
| 38 |
+
# 1.18+ superflat stacks the layers upward from the -64 world floor, and Voyager's
|
| 39 |
+
# placement, navigation and prompts are all pinned to a walkable surface at Y=64,
|
| 40 |
+
# so the heights below must total exactly WORLD_FLOOR + WORLD_SURFACE_Y.
|
| 41 |
+
# Every ore is duplicated: a shallow band 6-24 blocks under the topsoil that a
|
| 42 |
+
# mining bot reaches from the surface, and a deepslate band at the bottom of the
|
| 43 |
+
# column. Plains with no lakes and no features keeps the surface predictable for
|
| 44 |
+
# the pathfinder and for players building on it.
|
| 45 |
+
WORLD_FLAT_LAYERS = (
|
| 46 |
+
("minecraft:bedrock", 1),
|
| 47 |
+
("minecraft:deepslate", 6),
|
| 48 |
+
("minecraft:deepslate_coal_ore", 3),
|
| 49 |
+
("minecraft:deepslate_copper_ore", 3),
|
| 50 |
+
("minecraft:deepslate_iron_ore", 3),
|
| 51 |
+
("minecraft:deepslate_gold_ore", 2),
|
| 52 |
+
("minecraft:deepslate_redstone_ore", 2),
|
| 53 |
+
("minecraft:deepslate_diamond_ore", 1),
|
| 54 |
+
("minecraft:deepslate_lapis_ore", 1),
|
| 55 |
+
("minecraft:deepslate_emerald_ore", 1),
|
| 56 |
+
("minecraft:stone", 81),
|
| 57 |
+
("minecraft:coal_ore", 4),
|
| 58 |
+
("minecraft:copper_ore", 4),
|
| 59 |
+
("minecraft:iron_ore", 4),
|
| 60 |
+
("minecraft:gold_ore", 2),
|
| 61 |
+
("minecraft:redstone_ore", 2),
|
| 62 |
+
("minecraft:diamond_ore", 1),
|
| 63 |
+
("minecraft:lapis_ore", 1),
|
| 64 |
+
("minecraft:emerald_ore", 1),
|
| 65 |
+
("minecraft:gravel", 1),
|
| 66 |
+
("minecraft:dirt", 3),
|
| 67 |
+
("minecraft:grass_block", 1),
|
| 68 |
+
)
|
| 69 |
+
WORLD_FLOOR_Y = -64
|
| 70 |
+
if sum(height for _, height in WORLD_FLAT_LAYERS) != WORLD_SURFACE_Y - WORLD_FLOOR_Y:
|
| 71 |
+
raise RuntimeError(
|
| 72 |
+
"World layers must total the distance from the world floor to WORLD_SURFACE_Y"
|
| 73 |
+
)
|
| 74 |
+
FLAT_GENERATOR_SETTINGS = json.dumps(
|
| 75 |
+
{
|
| 76 |
+
"biome": "minecraft:plains",
|
| 77 |
+
"layers": [{"block": block, "height": height} for block, height in WORLD_FLAT_LAYERS],
|
| 78 |
+
"lakes": False,
|
| 79 |
+
"features": False,
|
| 80 |
+
"structure_overrides": [],
|
| 81 |
+
},
|
| 82 |
+
separators=(",", ":"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
)
|
| 84 |
BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot")
|
| 85 |
ADMIN_USERNAME = "IffyBinkie6134"
|
| 86 |
MAX_PLAYERS = 100
|
| 87 |
+
TOOLS_DIR = BASE / "tools"
|
| 88 |
+
BIN_DIR = BASE / "bin"
|
| 89 |
+
LOG_PATH = BASE / "stack.log"
|
| 90 |
+
READY_FILE = BASE / "minecraft.ready"
|
| 91 |
+
|
| 92 |
+
FABRIC_INSTALLER_URL = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/0.11.2/fabric-installer-0.11.2.jar"
|
| 93 |
+
FABRIC_MC_VERSION = "1.19"
|
| 94 |
+
FABRIC_LOADER_VERSION = "0.14.18"
|
| 95 |
+
|
| 96 |
+
# Official Voyager Fabric 1.19 server-side mod stack.
|
| 97 |
+
FABRIC_API_URL = "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0%2B1.19/fabric-api-0.58.0%2B1.19.jar"
|
| 98 |
+
FABRIC_API_SHA256 = "0a3d2dba1ed45b002483ae7c08e2c0bcfcb8d2b166801a6c29d8ea64dba64370"
|
| 99 |
+
COMPLETE_CONFIG_URL = "https://cdn.modrinth.com/data/GtqG8z1h/versions/2.0.0/completeconfig-2.0.0.jar"
|
| 100 |
+
COMPLETE_CONFIG_SHA256 = "d34a3e167bf7942348d3f8c650fcefead6284878b39277ba43282ef69fa2d566"
|
| 101 |
+
SERVER_PAUSE_URL = "https://mediafilez.forgecdn.net/files/3822/586/Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar"
|
| 102 |
+
SERVER_PAUSE_SHA256 = "4f92d4a4cef4627acbdfa1c601f0c58e392eee4159a69515fd0d622d3eeccd66"
|
| 103 |
+
BETTER_RESPAWN_URL = "https://cdn.modrinth.com/data/ZUQINcGy/versions/fabric-1.19-2.0.0/better-respawn-fabric-1.19-2.0.0.jar"
|
| 104 |
+
BETTER_RESPAWN_SHA256 = "9405e2ae53f031d5c32505dc3ee774c3f723c2d465230aaed7e6f7b3cdec0948"
|
| 105 |
+
|
| 106 |
+
VIAPROXY_URL = "https://github.com/ViaVersion/ViaProxy/releases/download/v3.4.13/ViaProxy-3.4.13.jar"
|
| 107 |
+
VIAPROXY_SHA256 = "4bbb6a6b9d3dd6a2028773ed44b97a98751e4a48e416aaad4bf6d9c860083be9"
|
| 108 |
+
|
| 109 |
+
GEYSER_URL = "https://download.geysermc.org/v2/projects/geyser/versions/2.11.3/builds/1247/downloads/viaproxy"
|
| 110 |
+
GEYSER_SHA256 = "85eea53e7fe58e4d97c2144a9d061577d37f619ab635227b68a948aa62d22924"
|
| 111 |
+
|
| 112 |
+
GO_URL = "https://go.dev/dl/go1.25.14.linux-amd64.tar.gz"
|
| 113 |
+
GO_SHA256 = "a21ae5633a269bcd7e90cf767e48225633795e99d831742cbf3397064fee7712"
|
| 114 |
+
|
| 115 |
+
JAVA_URL = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jre_x64_linux_hotspot_21.0.12.1_1.tar.gz"
|
| 116 |
+
JAVA_SHA256 = "2413149700df0f7d440500a84a8f764c535f21e5a5e87d38328b64eec2c5b500"
|
| 117 |
+
MC_JAVA_URL = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20.1%2B1/OpenJDK17U-jre_x64_linux_hotspot_17.0.20.1_1.tar.gz"
|
| 118 |
+
MC_JAVA_SHA256 = "0b2b640e3046b64c8ec504de0ab9d91bb5610182bda21fad454681ce54d45a62"
|
| 119 |
+
|
| 120 |
+
BEDROCK_PROTOCOL = int(os.environ.get("BEDROCK_PROTOCOL", "2193"))
|
| 121 |
+
BEDROCK_VERSION = os.environ.get("BEDROCK_VERSION", "1.26.51")
|
| 122 |
+
WORLD_NAME = os.environ.get("MINECRAFT_WORLD_NAME", "Voyager AI World")
|
| 123 |
+
HOST_NAME = os.environ.get("MINECRAFT_HOST_NAME", "Voyager AI")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _mkdirs() -> None:
|
| 127 |
+
for path in (BASE, PERSIST_ROOT, JAVA_DIR, VIA_DIR, RELAY_DIR, WORLD_DIR, TOOLS_DIR, BIN_DIR):
|
| 128 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _log(message: str) -> None:
|
| 132 |
+
_mkdirs()
|
| 133 |
+
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}"
|
| 134 |
+
print(line, flush=True)
|
| 135 |
+
with LOG_PATH.open("a", encoding="utf-8", errors="replace") as fh:
|
| 136 |
+
fh.write(line + "\n")
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _sha256(path: Path) -> str:
|
| 140 |
+
h = hashlib.sha256()
|
| 141 |
+
with path.open("rb") as fh:
|
| 142 |
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
| 143 |
+
h.update(chunk)
|
| 144 |
+
return h.hexdigest()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _download(url: str, path: Path, sha256: str | None = None) -> Path:
|
| 148 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 149 |
+
if path.exists() and (not sha256 or _sha256(path) == sha256):
|
| 150 |
+
return path
|
| 151 |
+
tmp = path.with_suffix(path.suffix + ".part")
|
| 152 |
+
tmp.unlink(missing_ok=True)
|
| 153 |
+
req = urllib.request.Request(url, headers={"User-Agent": "minecraft-friends-ai-zerogpu/1.0"})
|
| 154 |
+
_log(f"download {url}")
|
| 155 |
+
with urllib.request.urlopen(req, timeout=120) as response, tmp.open("wb") as out:
|
| 156 |
+
shutil.copyfileobj(response, out)
|
| 157 |
+
if sha256 and _sha256(tmp) != sha256:
|
| 158 |
+
tmp.unlink(missing_ok=True)
|
| 159 |
+
raise RuntimeError(f"SHA256 mismatch for {url}")
|
| 160 |
+
tmp.replace(path)
|
| 161 |
+
return path
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _wait_tcp(host: str, port: int, timeout: float) -> bool:
|
| 165 |
+
deadline = time.time() + timeout
|
| 166 |
+
while time.time() < deadline:
|
| 167 |
+
try:
|
| 168 |
+
with socket.create_connection((host, port), timeout=1):
|
| 169 |
+
return True
|
| 170 |
+
except OSError:
|
| 171 |
+
time.sleep(1)
|
| 172 |
+
return False
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _tail_lines(path: Path, max_lines: int = 5000) -> list[str]:
|
| 176 |
+
"""Read at most max_lines trailing lines without loading a days-long log.
|
| 177 |
+
|
| 178 |
+
status() runs on every supervisor tick and UI click, so a full read of an
|
| 179 |
+
ever-growing stack.log would stall the watchdog it is meant to serve.
|
| 180 |
+
"""
|
| 181 |
+
try:
|
| 182 |
+
with path.open("rb") as fh:
|
| 183 |
+
fh.seek(0, os.SEEK_END)
|
| 184 |
+
size = fh.tell()
|
| 185 |
+
chunk_size = 65536
|
| 186 |
+
data = b""
|
| 187 |
+
position = size
|
| 188 |
+
while position > 0 and data.count(b"\n") <= max_lines:
|
| 189 |
+
step = min(chunk_size, position)
|
| 190 |
+
position -= step
|
| 191 |
+
fh.seek(position)
|
| 192 |
+
data = fh.read(step) + data
|
| 193 |
+
if len(data) > (max_lines + 1) * 512:
|
| 194 |
+
break
|
| 195 |
+
return data.decode("utf-8", errors="replace").splitlines()[-max_lines:]
|
| 196 |
+
except OSError:
|
| 197 |
+
return []
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _run_capture(args: list[str], cwd: Path, timeout: int = 300, env: dict[str, str] | None = None) -> str:
|
| 201 |
+
_log("exec " + " ".join(args))
|
| 202 |
+
proc = subprocess.run(
|
| 203 |
+
args,
|
| 204 |
+
cwd=str(cwd),
|
| 205 |
+
env=env,
|
| 206 |
+
text=True,
|
| 207 |
+
stdout=subprocess.PIPE,
|
| 208 |
+
stderr=subprocess.STDOUT,
|
| 209 |
+
timeout=timeout,
|
| 210 |
+
check=False,
|
| 211 |
+
)
|
| 212 |
+
output = proc.stdout or ""
|
| 213 |
+
for line in output.splitlines()[-80:]:
|
| 214 |
+
_log("setup> " + line)
|
| 215 |
+
if proc.returncode != 0:
|
| 216 |
+
raise RuntimeError(f"command failed rc={proc.returncode}: {' '.join(args)}")
|
| 217 |
+
return output
|
| 218 |
+
|
| 219 |
+
|
| 220 |
def _patch_scalar(text: str, key: str, value: str) -> str:
|
| 221 |
+
pattern = rf"(?m)^(\s*{re.escape(key)}:\s*).*$"
|
| 222 |
+
if re.search(pattern, text):
|
| 223 |
+
return re.sub(pattern, lambda m: m.group(1) + value, text, count=1)
|
| 224 |
return text + f"\n{key}: {value}\n"
|
| 225 |
|
| 226 |
|
|
|
|
| 277 |
}
|
| 278 |
|
| 279 |
|
| 280 |
+
def _offline_player_uuid(username: str) -> str:
|
| 281 |
+
return str(uuid.UUID(bytes=hashlib.md5(
|
| 282 |
+
f"OfflinePlayer:{username}".encode("utf-8")
|
| 283 |
+
).digest(), version=3))
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _carry_player_saves(previous: Path, current: Path) -> None:
|
| 287 |
+
"""Copy human player saves into a newly generated world.
|
| 288 |
+
|
| 289 |
+
The Xbox OAuth token lives in RELAY_DIR and is never touched here, so a map
|
| 290 |
+
change must not cost a player their session. Player saves are copied instead
|
| 291 |
+
of the world itself: they only store a player's position, inventory and
|
| 292 |
+
gamemode, which are valid in any world of the same dimension.
|
| 293 |
+
|
| 294 |
+
The bot is deliberately excluded. A full bot inventory is what pins the
|
| 295 |
+
official CurriculumAgent to its hardcoded full-inventory branch, so a fresh
|
| 296 |
+
world has to start the bot from an empty save.
|
| 297 |
+
"""
|
| 298 |
+
source = previous / "playerdata"
|
| 299 |
+
if not source.is_dir():
|
| 300 |
+
return
|
| 301 |
+
destination = current / "playerdata"
|
| 302 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 303 |
+
bot_save = f"{_offline_player_uuid(BOT_USERNAME)}.dat"
|
| 304 |
+
carried = []
|
| 305 |
+
for save in sorted(source.glob("*.dat")):
|
| 306 |
+
if save.name == bot_save:
|
| 307 |
+
continue
|
| 308 |
+
try:
|
| 309 |
+
shutil.copy2(save, destination / save.name)
|
| 310 |
+
except OSError as exc:
|
| 311 |
+
_log(f"could not carry player save {save.name}: {exc}")
|
| 312 |
+
continue
|
| 313 |
+
carried.append(save.name)
|
| 314 |
+
if carried:
|
| 315 |
+
_log(f"Carried {len(carried)} player save(s) into the new world: {', '.join(carried)}")
|
| 316 |
+
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
_log("No human player saves to carry into the new world")
|
| 318 |
|
| 319 |
|
|
|
|
| 325 |
tick_lag = [line for line in recent if "Can't keep up!" in line]
|
| 326 |
delays = [int(match.group(1)) for line in tick_lag
|
| 327 |
if (match := re.search(r"Running (\d+)ms or \d+ ticks behind", line))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
return {
|
| 329 |
"window_seconds": 300,
|
| 330 |
"tick_lag_events": len(tick_lag),
|
| 331 |
"max_reported_tick_delay_ms": max(delays, default=None),
|
| 332 |
+
"movement_warnings": sum("moved too quickly" in line or "moved wrongly" in line
|
| 333 |
+
for line in recent),
|
| 334 |
"last_tick_lag_at": tick_lag[-1][1:20] if tick_lag else None,
|
| 335 |
}
|
| 336 |
|
|
|
|
| 371 |
)]
|
| 372 |
preserved.append(entry)
|
| 373 |
path.write_text(json.dumps(preserved, indent=2) + "\n", encoding="utf-8")
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
class MinecraftStack:
|
| 377 |
+
def __init__(self) -> None:
|
| 378 |
+
self.lock = threading.RLock()
|
| 379 |
+
self.worker: threading.Thread | None = None
|
| 380 |
+
self.processes: dict[str, subprocess.Popen[str]] = {}
|
| 381 |
+
self.state = "stopped"
|
| 382 |
+
self.error = ""
|
| 383 |
+
self.started_at: float | None = None
|
| 384 |
+
self.minecraft_ready = threading.Event()
|
| 385 |
+
self.failed_saves_since_boot = 0
|
| 386 |
+
self.respawn_npe_since_boot = 0
|
| 387 |
+
|
| 388 |
+
def _pump(self, name: str, proc: subprocess.Popen[str]) -> None:
|
| 389 |
+
assert proc.stdout is not None
|
| 390 |
+
try:
|
| 391 |
+
for line in proc.stdout:
|
| 392 |
+
clean = line.rstrip()
|
| 393 |
+
_log(f"{name}> {clean}")
|
| 394 |
+
if name == "minecraft":
|
| 395 |
+
with self.lock:
|
| 396 |
+
if "Failed to save player data" in clean:
|
| 397 |
+
self.failed_saves_since_boot += 1
|
| 398 |
+
if "respawnDimension" in clean and "null" in clean:
|
| 399 |
+
self.respawn_npe_since_boot += 1
|
| 400 |
+
if name == "minecraft" and "Done (" in clean:
|
| 401 |
+
self.minecraft_ready.set()
|
| 402 |
+
except Exception as exc:
|
| 403 |
+
_log(f"{name} log pump error: {exc!r}")
|
| 404 |
+
|
| 405 |
+
def _spawn(self, name: str, args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.Popen[str]:
|
| 406 |
+
_log(f"start {name}: {' '.join(args)}")
|
| 407 |
+
proc = subprocess.Popen(
|
| 408 |
+
args,
|
| 409 |
+
cwd=str(cwd),
|
| 410 |
+
env=env,
|
| 411 |
+
stdin=subprocess.PIPE,
|
| 412 |
+
stdout=subprocess.PIPE,
|
| 413 |
+
stderr=subprocess.STDOUT,
|
| 414 |
+
text=True,
|
| 415 |
+
bufsize=1,
|
| 416 |
+
)
|
| 417 |
+
self.processes[name] = proc
|
| 418 |
+
threading.Thread(target=self._pump, args=(name, proc), daemon=True, name=f"log-{name}").start()
|
| 419 |
+
return proc
|
| 420 |
+
|
| 421 |
+
def _stop_process(self, name: str) -> None:
|
| 422 |
+
proc = self.processes.get(name)
|
| 423 |
+
if not proc or proc.poll() is not None:
|
| 424 |
+
return
|
| 425 |
+
try:
|
| 426 |
+
if name == "minecraft" and proc.stdin:
|
| 427 |
+
proc.stdin.write("stop\n")
|
| 428 |
+
proc.stdin.flush()
|
| 429 |
+
proc.wait(timeout=20)
|
| 430 |
+
return
|
| 431 |
+
except Exception:
|
| 432 |
+
pass
|
| 433 |
+
try:
|
| 434 |
+
proc.terminate()
|
| 435 |
+
proc.wait(timeout=12)
|
| 436 |
+
except Exception:
|
| 437 |
+
try:
|
| 438 |
+
proc.kill()
|
| 439 |
+
except Exception:
|
| 440 |
+
pass
|
| 441 |
+
|
| 442 |
+
def _ensure_java(self) -> Path:
|
| 443 |
+
root = TOOLS_DIR / "jre21"
|
| 444 |
+
existing = list(root.glob("*/bin/java")) if root.exists() else []
|
| 445 |
+
if existing:
|
| 446 |
+
return existing[0]
|
| 447 |
+
archive = _download(JAVA_URL, TOOLS_DIR / "temurin21-jre.tar.gz", JAVA_SHA256)
|
| 448 |
+
if root.exists():
|
| 449 |
+
shutil.rmtree(root)
|
| 450 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 451 |
+
with tarfile.open(archive, "r:gz") as tf:
|
| 452 |
+
tf.extractall(root)
|
| 453 |
+
found = list(root.glob("*/bin/java"))
|
| 454 |
+
if not found:
|
| 455 |
+
raise RuntimeError("Temurin JRE 21 extraction failed")
|
| 456 |
+
found[0].chmod(0o755)
|
| 457 |
+
version = _run_capture([str(found[0]), "-version"], BASE, timeout=30)
|
| 458 |
+
_log("portable Java ready: " + version.splitlines()[0])
|
| 459 |
+
return found[0]
|
| 460 |
+
|
| 461 |
+
def _ensure_minecraft_java(self) -> Path:
|
| 462 |
+
root = TOOLS_DIR / "jre17"
|
| 463 |
+
existing = list(root.glob("*/bin/java")) if root.exists() else []
|
| 464 |
+
if existing:
|
| 465 |
+
return existing[0]
|
| 466 |
+
archive = _download(MC_JAVA_URL, TOOLS_DIR / "temurin17-jre.tar.gz", MC_JAVA_SHA256)
|
| 467 |
+
if root.exists():
|
| 468 |
+
shutil.rmtree(root)
|
| 469 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 470 |
+
with tarfile.open(archive, "r:gz") as tf:
|
| 471 |
+
tf.extractall(root)
|
| 472 |
+
found = list(root.glob("*/bin/java"))
|
| 473 |
+
if not found:
|
| 474 |
+
raise RuntimeError("Temurin JRE 17 extraction failed")
|
| 475 |
+
found[0].chmod(0o755)
|
| 476 |
+
version = _run_capture([str(found[0]), "-version"], BASE, timeout=30)
|
| 477 |
+
_log("Minecraft Java 17 ready: " + version.splitlines()[0])
|
| 478 |
+
return found[0]
|
| 479 |
+
|
| 480 |
+
def _ensure_persistent_world(self) -> None:
|
| 481 |
+
WORLD_DIR.mkdir(parents=True, exist_ok=True)
|
| 482 |
+
# level.dat only exists once the server has generated the world, so its
|
| 483 |
+
# absence means this is the first boot of a new map.
|
| 484 |
+
if not (WORLD_DIR / "level.dat").exists() and PREVIOUS_WORLD_DIR.is_dir():
|
| 485 |
+
_carry_player_saves(PREVIOUS_WORLD_DIR, WORLD_DIR)
|
| 486 |
+
# Probe the exact temporary-file + rename pattern used by player saves.
|
| 487 |
+
# This never touches a player's .dat file or existing world data.
|
| 488 |
+
player_dir = WORLD_DIR / "playerdata"
|
| 489 |
+
probe = None
|
| 490 |
+
target = player_dir / ".voyager-storage-probe"
|
| 491 |
+
try:
|
| 492 |
+
player_dir.mkdir(parents=True, exist_ok=True)
|
| 493 |
+
marker = player_dir / ".keep"
|
| 494 |
+
if not marker.exists():
|
| 495 |
+
marker.write_text("Preserve playerdata directory in object storage.\n", encoding="utf-8")
|
| 496 |
+
fd, filename = tempfile.mkstemp(prefix=".voyager-probe-", suffix=".dat", dir=player_dir)
|
| 497 |
+
probe = Path(filename)
|
| 498 |
+
with os.fdopen(fd, "wb") as handle:
|
| 499 |
+
handle.write(b"voyager storage check")
|
| 500 |
+
probe.replace(target)
|
| 501 |
+
assert target.read_bytes() == b"voyager storage check"
|
| 502 |
+
_log("Player storage probe: temporary create/write/rename/read succeeded")
|
| 503 |
+
except Exception as exc:
|
| 504 |
+
_log(f"PLAYER STORAGE PROBE FAILED: {type(exc).__name__}: {exc}")
|
| 505 |
+
finally:
|
| 506 |
+
for candidate in (probe, target):
|
| 507 |
+
if candidate is not None:
|
| 508 |
+
try:
|
| 509 |
+
candidate.unlink(missing_ok=True)
|
| 510 |
+
except OSError:
|
| 511 |
+
pass
|
| 512 |
+
WORLD_DIR.mkdir(parents=True, exist_ok=True)
|
| 513 |
+
world_link = JAVA_DIR / "world"
|
| 514 |
+
if world_link.is_symlink():
|
| 515 |
+
try:
|
| 516 |
+
if world_link.resolve() == WORLD_DIR.resolve():
|
| 517 |
+
return
|
| 518 |
+
except OSError:
|
| 519 |
+
pass
|
| 520 |
+
world_link.unlink(missing_ok=True)
|
| 521 |
+
elif world_link.exists():
|
| 522 |
+
if not any(WORLD_DIR.iterdir()):
|
| 523 |
+
shutil.copytree(world_link, WORLD_DIR, dirs_exist_ok=True)
|
| 524 |
+
_log("Migrated existing world into persistent storage")
|
| 525 |
+
shutil.rmtree(world_link)
|
| 526 |
+
world_link.symlink_to(WORLD_DIR, target_is_directory=True)
|
| 527 |
+
_log(f"Persistent Minecraft world linked: {world_link} -> {WORLD_DIR}")
|
| 528 |
+
|
| 529 |
+
def _ensure_voyager_mods(self) -> None:
|
| 530 |
+
mods_dir = JAVA_DIR / "mods"
|
| 531 |
+
mods_dir.mkdir(parents=True, exist_ok=True)
|
| 532 |
+
mods = (
|
| 533 |
+
("fabric-api-0.58.0+1.19.jar", FABRIC_API_URL, FABRIC_API_SHA256),
|
| 534 |
+
("completeconfig-2.0.0.jar", COMPLETE_CONFIG_URL, COMPLETE_CONFIG_SHA256),
|
| 535 |
+
("Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar", SERVER_PAUSE_URL, SERVER_PAUSE_SHA256),
|
| 536 |
+
)
|
| 537 |
for filename, url, sha256 in mods:
|
| 538 |
_download(url, mods_dir / filename, sha256)
|
| 539 |
|
|
|
|
| 547 |
_log("Removed faulty Better Respawn 1.19 mod from Fabric mods directory")
|
| 548 |
_log(
|
| 549 |
"Official Voyager Fabric mods ready: Fabric API 0.58.0+1.19, "
|
| 550 |
+
"CompleteConfig 2.0.0, Multiplayer Server Pause 1.1.0. "
|
|
|
|
| 551 |
"Better Respawn 1.19-2.0.0 is disabled due to its player-save NPE. Mod Menu is client-only and is "
|
| 552 |
+
"intentionally omitted on this headless dedicated server."
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
def _ensure_fabric(self) -> Path:
|
| 556 |
+
java = self._ensure_minecraft_java()
|
| 557 |
+
installer = _download(FABRIC_INSTALLER_URL, TOOLS_DIR / "fabric-installer-0.11.2.jar")
|
| 558 |
+
JAVA_DIR.mkdir(parents=True, exist_ok=True)
|
| 559 |
+
launcher = JAVA_DIR / "fabric-server-launch.jar"
|
| 560 |
+
if not launcher.exists():
|
| 561 |
+
_run_capture(
|
| 562 |
+
[
|
| 563 |
+
str(java), "-jar", str(installer), "server",
|
| 564 |
+
"-mcversion", FABRIC_MC_VERSION,
|
| 565 |
+
"-loader", FABRIC_LOADER_VERSION,
|
| 566 |
+
"-downloadMinecraft",
|
| 567 |
+
"-dir", str(JAVA_DIR),
|
| 568 |
+
],
|
| 569 |
+
JAVA_DIR,
|
| 570 |
+
timeout=300,
|
| 571 |
+
)
|
| 572 |
+
self._ensure_voyager_mods()
|
| 573 |
+
(JAVA_DIR / "eula.txt").write_text("eula=true\n", encoding="utf-8")
|
| 574 |
+
props = {
|
| 575 |
+
"server-ip": "127.0.0.1",
|
| 576 |
+
"server-port": "25565",
|
| 577 |
+
"online-mode": "false",
|
| 578 |
+
"enforce-secure-profile": "false",
|
| 579 |
+
"enable-command-block": "true",
|
| 580 |
+
"spawn-protection": "0",
|
| 581 |
+
"allow-flight": "true",
|
| 582 |
+
"gamemode": "creative",
|
| 583 |
+
"force-gamemode": "true",
|
| 584 |
"difficulty": "peaceful",
|
| 585 |
"max-players": str(MAX_PLAYERS),
|
| 586 |
"op-permission-level": "4",
|
| 587 |
+
"view-distance": "8",
|
| 588 |
+
"simulation-distance": "6",
|
| 589 |
+
"level-name": "world",
|
| 590 |
"level-type": "minecraft:flat",
|
| 591 |
"generator-settings": FLAT_GENERATOR_SETTINGS,
|
| 592 |
+
"generate-structures": "false",
|
| 593 |
+
"motd": "Voyager AI + Bedrock Friends",
|
| 594 |
+
}
|
| 595 |
(JAVA_DIR / "server.properties").write_text(
|
| 596 |
"\n".join(f"{k}={v}" for k, v in props.items()) + "\n",
|
| 597 |
encoding="utf-8",
|
| 598 |
)
|
| 599 |
_ensure_admin_operator(JAVA_DIR / "ops.json", ADMIN_USERNAME)
|
| 600 |
self._ensure_persistent_world()
|
| 601 |
+
return launcher
|
| 602 |
+
|
| 603 |
+
def _reset_boot_counters(self) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
with self.lock:
|
| 605 |
+
self.failed_saves_since_boot = 0
|
| 606 |
+
self.respawn_npe_since_boot = 0
|
| 607 |
+
|
| 608 |
+
def _start_fabric(self) -> None:
|
| 609 |
+
launcher = self._ensure_fabric()
|
| 610 |
+
self.minecraft_ready.clear()
|
| 611 |
+
self._reset_boot_counters()
|
| 612 |
+
READY_FILE.unlink(missing_ok=True)
|
| 613 |
+
proc = self._spawn(
|
| 614 |
+
"minecraft",
|
| 615 |
+
[str(self._ensure_minecraft_java()), "-Xms1G", "-Xmx8G",
|
| 616 |
+
"--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED",
|
| 617 |
+
f"-javaagent:{Path(__file__).resolve().parent / 'diagnostics/player-save-diagnostics.jar'}",
|
| 618 |
+
"-jar", str(launcher), "nogui"],
|
| 619 |
+
JAVA_DIR,
|
| 620 |
+
)
|
| 621 |
+
if not _wait_tcp("127.0.0.1", 25565, 120):
|
| 622 |
+
raise RuntimeError("Minecraft Java 1.19 did not open 127.0.0.1:25565")
|
| 623 |
+
if not self.minecraft_ready.wait(timeout=180):
|
| 624 |
+
raise RuntimeError("Minecraft Java opened 25565 but world did not finish loading")
|
| 625 |
+
if proc.stdin:
|
| 626 |
+
for cmd in (
|
| 627 |
+
f"op {BOT_USERNAME}",
|
| 628 |
+
f"gamemode creative {BOT_USERNAME}",
|
| 629 |
+
"gamerule keepInventory true",
|
| 630 |
+
"gamerule doDaylightCycle false",
|
| 631 |
+
"difficulty peaceful",
|
| 632 |
+
):
|
| 633 |
+
proc.stdin.write(cmd + "\n")
|
| 634 |
+
proc.stdin.flush()
|
| 635 |
+
READY_FILE.write_text("ready\n", encoding="utf-8")
|
| 636 |
+
_log("Minecraft Java 1.19/Fabric fully ready on 127.0.0.1:25565")
|
| 637 |
+
|
| 638 |
+
def _ensure_viaproxy(self) -> tuple[Path, Path]:
|
| 639 |
+
jar = _download(VIAPROXY_URL, VIA_DIR / "ViaProxy-3.4.13.jar", VIAPROXY_SHA256)
|
| 640 |
+
plugin_dir = VIA_DIR / "plugins"
|
| 641 |
+
plugin_dir.mkdir(parents=True, exist_ok=True)
|
| 642 |
+
_download(GEYSER_URL, plugin_dir / "Geyser-ViaProxy.jar", GEYSER_SHA256)
|
| 643 |
+
cfg = VIA_DIR / "viaproxy.yml"
|
| 644 |
+
if not cfg.exists():
|
| 645 |
+
proc = subprocess.Popen(
|
| 646 |
+
[str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)],
|
| 647 |
+
cwd=str(VIA_DIR),
|
| 648 |
+
stdout=subprocess.PIPE,
|
| 649 |
+
stderr=subprocess.STDOUT,
|
| 650 |
+
text=True,
|
| 651 |
+
)
|
| 652 |
+
deadline = time.time() + 25
|
| 653 |
+
while time.time() < deadline and not cfg.exists() and proc.poll() is None:
|
| 654 |
+
time.sleep(1)
|
| 655 |
+
if proc.poll() is None:
|
| 656 |
+
proc.terminate()
|
| 657 |
+
try:
|
| 658 |
+
proc.wait(timeout=8)
|
| 659 |
+
except Exception:
|
| 660 |
+
proc.kill()
|
| 661 |
+
if not cfg.exists():
|
| 662 |
+
raise RuntimeError("ViaProxy did not generate viaproxy.yml")
|
| 663 |
+
text = cfg.read_text(encoding="utf-8", errors="replace")
|
| 664 |
+
text = _patch_scalar(text, "bind-address", "127.0.0.1:25568")
|
| 665 |
+
text = _patch_scalar(text, "target-address", "127.0.0.1:25565")
|
| 666 |
+
text = _patch_scalar(text, "proxy-online-mode", "false")
|
| 667 |
+
text = _patch_scalar(text, "auth-method", "none")
|
| 668 |
+
cfg.write_text(text, encoding="utf-8")
|
| 669 |
+
return jar, cfg
|
| 670 |
+
|
| 671 |
+
def _patch_geyser(self, path: Path) -> None:
|
| 672 |
+
data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
| 673 |
+
bedrock = data.setdefault("bedrock", {})
|
| 674 |
+
bedrock["address"] = "127.0.0.1"
|
| 675 |
+
bedrock["port"] = 19132
|
| 676 |
+
bedrock["clone-remote-port"] = False
|
| 677 |
+
java = data.setdefault("java", {})
|
| 678 |
+
java["auth-type"] = "offline"
|
| 679 |
+
advanced = data.setdefault("advanced", {})
|
| 680 |
+
adv_bedrock = advanced.setdefault("bedrock", {})
|
| 681 |
+
adv_bedrock["broadcast-port"] = 19132
|
| 682 |
+
adv_bedrock["validate-bedrock-login"] = False
|
| 683 |
+
data["debug-mode"] = False
|
| 684 |
+
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
| 685 |
+
_log("Geyser configured loopback-only; Xbox/NetherNet relay remains the auth boundary")
|
| 686 |
+
|
| 687 |
+
def _start_viaproxy(self) -> None:
|
| 688 |
+
jar, cfg = self._ensure_viaproxy()
|
| 689 |
+
geyser_cfg = VIA_DIR / "plugins" / "Geyser" / "config.yml"
|
| 690 |
+
if not geyser_cfg.exists():
|
| 691 |
+
proc = self._spawn("viaproxy-bootstrap", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
|
| 692 |
+
deadline = time.time() + 35
|
| 693 |
+
while time.time() < deadline and not geyser_cfg.exists() and proc.poll() is None:
|
| 694 |
+
time.sleep(1)
|
| 695 |
+
self._stop_process("viaproxy-bootstrap")
|
| 696 |
+
if not geyser_cfg.exists():
|
| 697 |
+
raise RuntimeError("Geyser-ViaProxy did not generate plugins/Geyser/config.yml")
|
| 698 |
+
self._patch_geyser(geyser_cfg)
|
| 699 |
+
proc = self._spawn("viaproxy", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
|
| 700 |
+
time.sleep(12)
|
| 701 |
+
if proc.poll() is not None:
|
| 702 |
+
raise RuntimeError(f"ViaProxy/Geyser exited rc={proc.returncode}")
|
| 703 |
+
_log("ViaProxy + Geyser running; Bedrock backend is 127.0.0.1:19132")
|
| 704 |
+
|
| 705 |
+
def _ensure_go(self) -> Path:
|
| 706 |
+
go = TOOLS_DIR / "go" / "bin" / "go"
|
| 707 |
+
if go.exists():
|
| 708 |
+
return go
|
| 709 |
+
archive = _download(GO_URL, TOOLS_DIR / "go1.25.14.linux-amd64.tar.gz", GO_SHA256)
|
| 710 |
+
target = TOOLS_DIR / "go"
|
| 711 |
+
if target.exists():
|
| 712 |
+
shutil.rmtree(target)
|
| 713 |
+
with tarfile.open(archive, "r:gz") as tf:
|
| 714 |
+
tf.extractall(TOOLS_DIR)
|
| 715 |
+
if not go.exists():
|
| 716 |
+
raise RuntimeError("Go toolchain extraction failed")
|
| 717 |
+
return go
|
| 718 |
+
|
| 719 |
+
def _ensure_nether2rak(self) -> Path:
|
| 720 |
+
binary = BIN_DIR / "nether2rak"
|
| 721 |
+
if binary.exists():
|
| 722 |
+
return binary
|
| 723 |
+
go = self._ensure_go()
|
| 724 |
+
src = Path("/app/third_party/nether2rak")
|
| 725 |
+
if not src.exists():
|
| 726 |
+
src = Path(__file__).resolve().parent / "third_party" / "nether2rak"
|
| 727 |
+
if not src.exists():
|
| 728 |
+
raise RuntimeError("Vendored nether2rak source is missing")
|
| 729 |
+
env = os.environ.copy()
|
| 730 |
+
env.update(
|
| 731 |
+
{
|
| 732 |
+
"PATH": f"{go.parent}:{env.get('PATH', '')}",
|
| 733 |
+
"GOTOOLCHAIN": "local",
|
| 734 |
+
"GOCACHE": str(BASE / "go-cache"),
|
| 735 |
+
"GOMODCACHE": str(BASE / "go-mod-cache"),
|
| 736 |
+
"CGO_ENABLED": "0",
|
| 737 |
+
}
|
| 738 |
+
)
|
| 739 |
+
_run_capture([str(go), "build", "-trimpath", "-o", str(binary), "."], src, timeout=900, env=env)
|
| 740 |
+
binary.chmod(0o755)
|
| 741 |
+
return binary
|
| 742 |
+
|
| 743 |
+
def _write_relay_config(self) -> Path:
|
| 744 |
+
cfg = {
|
| 745 |
+
"backend_transport": "raknet",
|
| 746 |
+
"geyser_address": "127.0.0.1:19132",
|
| 747 |
+
"nethernet_backend_address": "http://127.0.0.1:19134",
|
| 748 |
+
"host_name": HOST_NAME,
|
| 749 |
+
"world_name": WORLD_NAME,
|
| 750 |
+
"max_players": MAX_PLAYERS,
|
| 751 |
+
"fake_player_count": 0,
|
| 752 |
+
"protocol": BEDROCK_PROTOCOL,
|
| 753 |
+
"version": BEDROCK_VERSION,
|
| 754 |
+
"update_interval_seconds": 30,
|
| 755 |
+
"allowed_xuids": [],
|
| 756 |
+
"compression": "snappy",
|
| 757 |
+
"compression_threshold": 256,
|
| 758 |
+
"fix_native_bds_persistence": False,
|
| 759 |
+
"disable_client_encryption": False,
|
| 760 |
+
"relay_direct_ip": False,
|
| 761 |
+
"relay_direct_ip_listen_address": ":19134",
|
| 762 |
+
"invite_port": 7782,
|
| 763 |
+
"ping_port": 7777,
|
| 764 |
+
"pprof_port": 6060,
|
| 765 |
+
"extra_broadcasts": [],
|
| 766 |
+
}
|
| 767 |
+
path = RELAY_DIR / "config.json"
|
| 768 |
+
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
| 769 |
+
return path
|
| 770 |
+
|
| 771 |
+
def _start_relay(self) -> None:
|
| 772 |
+
binary = self._ensure_nether2rak()
|
| 773 |
+
self._write_relay_config()
|
| 774 |
+
proc = self._spawn("friends", [str(binary)], RELAY_DIR)
|
| 775 |
+
time.sleep(4)
|
| 776 |
+
if proc.poll() is not None:
|
| 777 |
+
raise RuntimeError(f"Xbox Friends relay exited rc={proc.returncode}")
|
| 778 |
+
_log("Xbox Friends relay started; Microsoft device-code login may be required")
|
| 779 |
+
|
| 780 |
+
def _boot(self) -> None:
|
| 781 |
+
with self.lock:
|
| 782 |
+
self.state = "starting"
|
| 783 |
+
self.error = ""
|
| 784 |
+
self.started_at = time.time()
|
| 785 |
+
try:
|
| 786 |
+
_mkdirs()
|
| 787 |
+
_log("=== Minecraft stack bootstrap ===")
|
| 788 |
+
self._start_fabric()
|
| 789 |
+
self._start_viaproxy()
|
| 790 |
+
self._start_relay()
|
| 791 |
+
with self.lock:
|
| 792 |
+
self.state = "running"
|
| 793 |
+
_log("=== Minecraft stack running ===")
|
| 794 |
+
except Exception as exc:
|
| 795 |
+
with self.lock:
|
| 796 |
+
self.state = "error"
|
| 797 |
+
self.error = repr(exc)
|
| 798 |
+
_log(f"STACK ERROR: {exc!r}")
|
| 799 |
+
|
| 800 |
+
def start_async(self) -> dict[str, Any]:
|
| 801 |
+
with self.lock:
|
| 802 |
+
if self.worker and self.worker.is_alive():
|
| 803 |
+
return self.status()
|
| 804 |
+
if any(p.poll() is None for p in self.processes.values()) and self.state == "running":
|
| 805 |
+
return self.status()
|
| 806 |
+
self.worker = threading.Thread(target=self._boot, daemon=True, name="minecraft-stack")
|
| 807 |
+
self.worker.start()
|
| 808 |
+
return self.status()
|
| 809 |
+
|
| 810 |
+
def stop(self) -> dict[str, Any]:
|
| 811 |
+
with self.lock:
|
| 812 |
+
for name in ("friends", "viaproxy", "viaproxy-bootstrap", "minecraft"):
|
| 813 |
+
self._stop_process(name)
|
| 814 |
+
self.state = "stopped"
|
| 815 |
+
_log("Minecraft stack stopped")
|
| 816 |
+
return self.status()
|
| 817 |
+
|
| 818 |
+
def tail(self, lines: int = 160) -> str:
|
| 819 |
+
if not LOG_PATH.exists():
|
| 820 |
+
return "Sem logs do Minecraft ainda."
|
| 821 |
+
items = _tail_lines(LOG_PATH, max(10, min(lines, 500)))
|
| 822 |
+
return "\n".join(items[-max(10, min(lines, 500)):])
|
| 823 |
+
|
| 824 |
def status(self) -> dict[str, Any]:
|
| 825 |
with self.lock:
|
| 826 |
processes = {
|
|
|
|
| 830 |
for name, process in processes.items():
|
| 831 |
if process["running"]:
|
| 832 |
process["rss_gib"] = _process_rss_gib(process["pid"])
|
| 833 |
+
all_lines = _tail_lines(LOG_PATH)
|
| 834 |
+
boot = next((i for i in range(len(all_lines) - 1, -1, -1)
|
| 835 |
+
if "=== Minecraft stack bootstrap ===" in all_lines[i]), 0)
|
| 836 |
+
server_lines = [line for line in all_lines[boot:] if "minecraft>" in line]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
log = "\n".join(all_lines[-120:]) if all_lines else "Sem logs do Minecraft ainda."
|
| 838 |
relay_health = _relay_diagnostics(all_lines[boot:] if all_lines else [])
|
| 839 |
access_state = _access_state(self.state, self.started_at, relay_health)
|
|
|
|
| 868 |
player_stat = None
|
| 869 |
player_save = {
|
| 870 |
"better_respawn_jar_present": (JAVA_DIR / "mods" / "better-respawn-fabric-1.19-2.0.0.jar").exists(),
|
| 871 |
+
"failed_saves_since_boot": self.failed_saves_since_boot,
|
| 872 |
+
"respawn_npe_since_boot": self.respawn_npe_since_boot,
|
|
|
|
| 873 |
"player_file_exists": player_stat is not None,
|
| 874 |
"player_file_size": player_stat.st_size if player_stat else None,
|
| 875 |
"player_file_mtime": player_stat.st_mtime if player_stat else None,
|
| 876 |
}
|
| 877 |
+
login_url = None
|
| 878 |
+
device_code = None
|
| 879 |
+
url_match = re.findall(r"https://(?:www\.)?microsoft\.com/link", log, flags=re.I)
|
| 880 |
+
if url_match:
|
| 881 |
+
login_url = url_match[-1]
|
| 882 |
+
for pattern in (
|
| 883 |
+
r"(?:code|c[oó]digo)\s*[:=]\s*([A-Z0-9-]{6,16})",
|
| 884 |
+
r"enter\s+([A-Z0-9-]{6,16})\s+(?:to|at)",
|
| 885 |
+
r"using\s+the\s+code\s+([A-Z0-9-]{6,16})",
|
| 886 |
+
):
|
| 887 |
+
found = re.findall(pattern, log, flags=re.I)
|
| 888 |
+
if found:
|
| 889 |
+
device_code = found[-1].upper()
|
| 890 |
+
break
|
| 891 |
+
return {
|
| 892 |
+
"state": self.state,
|
| 893 |
+
"error": self.error or None,
|
| 894 |
+
"started_at": self.started_at,
|
| 895 |
+
"java_server": "127.0.0.1:25565",
|
| 896 |
+
"bedrock_backend": "127.0.0.1:19132",
|
| 897 |
+
"access": "Xbox/Microsoft Friends via NetherNet; no public UDP tunnel",
|
| 898 |
+
"auth_persistence": str(RELAY_DIR / "token.json"),
|
| 899 |
+
"auth_token_cached": (RELAY_DIR / "token.json").exists(),
|
| 900 |
+
"persistent_root": str(PERSIST_ROOT),
|
| 901 |
"world_persistence": str(WORLD_DIR),
|
| 902 |
"world_profile": WORLD_PROFILE,
|
| 903 |
"world_surface_y": WORLD_SURFACE_Y,
|
| 904 |
"player_save": player_save,
|
| 905 |
"relay_health": relay_health,
|
|
|
|
| 906 |
"access_state": access_state,
|
| 907 |
"performance": performance,
|
| 908 |
"player_limit": {"java": java_player_limit, "xbox_friends": xbox_player_limit},
|
| 909 |
"admin_operator": admin_operator,
|
| 910 |
"admin_player_file_exists": admin_player_file_exists,
|
| 911 |
+
"bot_username": BOT_USERNAME,
|
| 912 |
+
"bot_display_name": "Naldo Bot",
|
| 913 |
+
"bedrock_protocol": BEDROCK_PROTOCOL,
|
| 914 |
+
"bedrock_version": BEDROCK_VERSION,
|
| 915 |
+
"microsoft_login_url": login_url,
|
| 916 |
+
"microsoft_device_code": device_code,
|
| 917 |
+
"processes": processes,
|
| 918 |
+
"log_tail": log,
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
|
| 922 |
+
STACK = MinecraftStack()
|
naldo_opencode.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Naldo_Bot brain constants and offline-safe helpers for the OpenCode path.
|
| 2 |
+
|
| 3 |
+
Only Naldo_Bot uses OpenCode. Gemini_Bot keeps its own proxy and must not
|
| 4 |
+
import this module's model list as a fallback.
|
| 5 |
+
|
| 6 |
+
Design notes (do not drift without updating the tests):
|
| 7 |
+
- Provider is always ``opencode``. Only FREE opencode models are allowed.
|
| 8 |
+
Never Granite, Gemini, external OpenAI, Anthropic, or any other family.
|
| 9 |
+
- No OPENCODE_API_KEY is required: the free opencode models work with cost 0
|
| 10 |
+
in a clean HOME. This module never reads, copies, or logs credential files.
|
| 11 |
+
- ``opencode serve`` binds 127.0.0.1 only, on a dedicated internal port.
|
| 12 |
+
- The bridge (naldo_opencode_bridge.py) binds 127.0.0.1 only on its own port
|
| 13 |
+
and translates OpenAI-compatible chat calls into OpenCode session calls:
|
| 14 |
+
POST /session -> POST /session/{id}/message, health via GET /global/health.
|
| 15 |
+
- OpenCode session message API exposes no temperature field, so the bridge
|
| 16 |
+
cannot forward Voyager's temperature. The requested value is recorded for
|
| 17 |
+
observability; the effective sampling is the provider default.
|
| 18 |
+
- OpenCode here is a TEXT GENERATOR for Voyager agents (Curriculum/Action/
|
| 19 |
+
Critic/SkillManager). In OpenCode 1.18.33, message-level ``tools: {}``
|
| 20 |
+
disables tool execution while remaining compatible with the Zen free tier.
|
| 21 |
+
Custom deny-all session permissions are intentionally not sent because Zen
|
| 22 |
+
rejects those customized sessions with FreeTierError/403.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import subprocess
|
| 30 |
+
import urllib.parse
|
| 31 |
+
import urllib.request
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Any
|
| 34 |
+
|
| 35 |
+
OPENCODE_PROVIDER = "opencode"
|
| 36 |
+
OPENCODE_VERSION = "1.18.33"
|
| 37 |
+
OPENCODE_NPM_SPEC = "opencode-ai@1.18.33"
|
| 38 |
+
|
| 39 |
+
PRIMARY_MODEL = "muse-spark-1.3-contributor-free"
|
| 40 |
+
FALLBACK_MODELS: tuple[str, ...] = (
|
| 41 |
+
"muse-spark-1.3-contributor-free",
|
| 42 |
+
"fledge-alpha-free",
|
| 43 |
+
"nemotron-3-ultra-free",
|
| 44 |
+
)
|
| 45 |
+
ALLOWED_MODELS = frozenset(FALLBACK_MODELS)
|
| 46 |
+
|
| 47 |
+
BRAIN_PROFILE = "naldo-opencode"
|
| 48 |
+
|
| 49 |
+
RUNTIME_HOME = Path(os.environ.get("VOYAGER_RUNTIME_HOME", "/tmp/voyager-runtime"))
|
| 50 |
+
OPENCODE_INSTALL_DIR = Path(os.environ.get("OPENCODE_INSTALL_DIR", str(RUNTIME_HOME / "opencode-cli")))
|
| 51 |
+
OPENCODE_HOME_DIR = Path(os.environ.get("OPENCODE_HOME_DIR", str(RUNTIME_HOME / "opencode-home")))
|
| 52 |
+
OPENCODE_SESSION_DIR = Path(os.environ.get("OPENCODE_SESSION_DIR", str(RUNTIME_HOME / "opencode-sessions")))
|
| 53 |
+
|
| 54 |
+
OPENCODE_PORT = int(os.environ.get("OPENCODE_PORT", "7865"))
|
| 55 |
+
BRIDGE_PORT = int(os.environ.get("NALDO_BRIDGE_PORT", "7864"))
|
| 56 |
+
OPENCODE_BASE = os.environ.get("OPENCODE_BASE", f"http://127.0.0.1:{OPENCODE_PORT}").rstrip("/")
|
| 57 |
+
BRIDGE_BASE = os.environ.get("NALDO_BRIDGE_BASE", f"http://127.0.0.1:{BRIDGE_PORT}/v1").rstrip("/")
|
| 58 |
+
|
| 59 |
+
# Local embeddings stay on the public 7860 endpoint so SkillManager/QA keep
|
| 60 |
+
# working. Only generative chat moves to the OpenCode bridge.
|
| 61 |
+
LOCAL_EMBED_BASE = os.environ.get("NALDO_EMBED_BASE", "http://127.0.0.1:7860/v1").rstrip("/")
|
| 62 |
+
|
| 63 |
+
FORBIDDEN_MODEL_SUBSTRINGS = (
|
| 64 |
+
"granite",
|
| 65 |
+
"gemini",
|
| 66 |
+
"gpt-",
|
| 67 |
+
"openai",
|
| 68 |
+
"anthropic",
|
| 69 |
+
"claude",
|
| 70 |
+
"qwen",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
| 74 |
+
|
| 75 |
+
TEMPERATURE_NOTE = (
|
| 76 |
+
"OpenCode session message API exposes no temperature field; "
|
| 77 |
+
"Voyager's requested temperature is recorded but not forwarded. "
|
| 78 |
+
"Effective sampling is the opencode provider default."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def canonical_model_id(name: str) -> str:
|
| 83 |
+
"""Strip an optional ``opencode/`` prefix to the bare free-model id."""
|
| 84 |
+
text = str(name or "").strip()
|
| 85 |
+
if "/" in text:
|
| 86 |
+
provider, _, model = text.partition("/")
|
| 87 |
+
if provider.strip().lower() == OPENCODE_PROVIDER:
|
| 88 |
+
return model.strip()
|
| 89 |
+
return text
|
| 90 |
+
return text
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def is_allowed_model(name: str) -> bool:
|
| 94 |
+
"""True only for FREE models of the ``opencode`` provider."""
|
| 95 |
+
text = str(name or "").strip()
|
| 96 |
+
if not text:
|
| 97 |
+
return False
|
| 98 |
+
lowered = text.lower()
|
| 99 |
+
for forbidden in FORBIDDEN_MODEL_SUBSTRINGS:
|
| 100 |
+
if forbidden in lowered:
|
| 101 |
+
return False
|
| 102 |
+
if "/" in text:
|
| 103 |
+
provider, _, model = text.partition("/")
|
| 104 |
+
if provider.strip().lower() != OPENCODE_PROVIDER:
|
| 105 |
+
return False
|
| 106 |
+
return model.strip() in ALLOWED_MODELS
|
| 107 |
+
return text in ALLOWED_MODELS
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def is_loopback_url(url: str) -> bool:
|
| 111 |
+
try:
|
| 112 |
+
host = (urllib.parse.urlparse(url).hostname or "").lower()
|
| 113 |
+
except Exception:
|
| 114 |
+
return False
|
| 115 |
+
return host in LOOPBACK_HOSTS
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _content(value: Any) -> str:
|
| 119 |
+
if value is None:
|
| 120 |
+
return ""
|
| 121 |
+
if isinstance(value, str):
|
| 122 |
+
return value
|
| 123 |
+
if isinstance(value, list):
|
| 124 |
+
parts: list[str] = []
|
| 125 |
+
for item in value:
|
| 126 |
+
if isinstance(item, dict):
|
| 127 |
+
text = item.get("text")
|
| 128 |
+
if isinstance(text, str):
|
| 129 |
+
parts.append(text)
|
| 130 |
+
elif isinstance(item, str):
|
| 131 |
+
parts.append(item)
|
| 132 |
+
return "\n".join(parts)
|
| 133 |
+
return str(value)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def build_opencode_prompt(messages: list[dict[str, Any]]) -> dict[str, str]:
|
| 137 |
+
"""Render OpenAI-style messages into a deterministic role-tagged prompt.
|
| 138 |
+
|
| 139 |
+
System messages go to the OpenCode ``system`` field AND stay tagged in the
|
| 140 |
+
prompt body so role boundaries survive a single-text-part transport.
|
| 141 |
+
tool_calls/tool outputs are rendered as inert text; they are never
|
| 142 |
+
executed. OpenCode tools stay disabled server-side.
|
| 143 |
+
"""
|
| 144 |
+
systems: list[str] = []
|
| 145 |
+
rendered: list[str] = []
|
| 146 |
+
has_tool_traffic = False
|
| 147 |
+
for raw in messages or []:
|
| 148 |
+
if not isinstance(raw, dict):
|
| 149 |
+
continue
|
| 150 |
+
role = str(raw.get("role", "user")).upper()
|
| 151 |
+
body = _content(raw.get("content"))
|
| 152 |
+
extra = ""
|
| 153 |
+
tool_calls = raw.get("tool_calls")
|
| 154 |
+
if isinstance(tool_calls, list) and tool_calls:
|
| 155 |
+
has_tool_traffic = True
|
| 156 |
+
try:
|
| 157 |
+
extra = "\n[TOOL_CALLS]\n" + json.dumps(tool_calls, ensure_ascii=False)[:4000]
|
| 158 |
+
except Exception:
|
| 159 |
+
extra = "\n[TOOL_CALLS]\n<unserializable>"
|
| 160 |
+
if raw.get("tool_call_id") or role == "TOOL":
|
| 161 |
+
has_tool_traffic = True
|
| 162 |
+
if role == "SYSTEM":
|
| 163 |
+
if body:
|
| 164 |
+
systems.append(body)
|
| 165 |
+
rendered.append(f"[SYSTEM]\n{body}{extra}")
|
| 166 |
+
else:
|
| 167 |
+
rendered.append(f"[{role}]\n{body}{extra}")
|
| 168 |
+
header = (
|
| 169 |
+
"You are the language model inside the Voyager Minecraft agent. "
|
| 170 |
+
"Follow the supplied system and user instructions exactly. "
|
| 171 |
+
"Return only the response format requested by the final prompt; no wrapper commentary. "
|
| 172 |
+
"OpenCode tools are disabled; you are a text generator, not a file/shell executor."
|
| 173 |
+
)
|
| 174 |
+
if has_tool_traffic:
|
| 175 |
+
header += (
|
| 176 |
+
" Note: any TOOL_CALLS/TOOL sections above are rendered as inert text "
|
| 177 |
+
"from the client; do not attempt tool calls, return plain text only."
|
| 178 |
+
)
|
| 179 |
+
system_text = "\n\n".join(systems)
|
| 180 |
+
prompt = header + "\n\n" + "\n\n".join(rendered) if rendered else header
|
| 181 |
+
return {"system": system_text, "prompt": prompt}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def extract_assistant_text(payload: Any) -> str:
|
| 185 |
+
"""Return only assistant ``text`` parts from an OpenCode message result."""
|
| 186 |
+
parts: Any = None
|
| 187 |
+
if isinstance(payload, dict):
|
| 188 |
+
parts = payload.get("parts", payload.get("message", {}).get("parts") if isinstance(payload.get("message"), dict) else None)
|
| 189 |
+
if parts is None and isinstance(payload.get("info"), dict):
|
| 190 |
+
# Some shapes nest under info/parts; fall through to generic scan.
|
| 191 |
+
parts = payload.get("parts")
|
| 192 |
+
elif isinstance(payload, list):
|
| 193 |
+
parts = payload
|
| 194 |
+
if not isinstance(parts, list):
|
| 195 |
+
return ""
|
| 196 |
+
texts: list[str] = []
|
| 197 |
+
for part in parts:
|
| 198 |
+
if not isinstance(part, dict):
|
| 199 |
+
continue
|
| 200 |
+
if part.get("type") != "text":
|
| 201 |
+
continue
|
| 202 |
+
text = part.get("text")
|
| 203 |
+
if isinstance(text, str) and text:
|
| 204 |
+
texts.append(text)
|
| 205 |
+
return "".join(texts)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def opencode_message_body(model_id: str, system: str, prompt: str) -> dict[str, Any]:
|
| 209 |
+
"""Build POST /session/{id}/message body with tools blocked.
|
| 210 |
+
|
| 211 |
+
No temperature is sent: the session API has no such field (see
|
| 212 |
+
TEMPERATURE_NOTE). ``tools: {}`` disables file/shell execution.
|
| 213 |
+
"""
|
| 214 |
+
body: dict[str, Any] = {
|
| 215 |
+
"model": {"providerID": OPENCODE_PROVIDER, "modelID": canonical_model_id(model_id)},
|
| 216 |
+
"tools": {},
|
| 217 |
+
"parts": [{"type": "text", "text": prompt}],
|
| 218 |
+
}
|
| 219 |
+
if system:
|
| 220 |
+
body["system"] = system
|
| 221 |
+
return body
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def opencode_binary_path(install_dir: Path = OPENCODE_INSTALL_DIR) -> Path:
|
| 225 |
+
name = "opencode.cmd" if os.name == "nt" else "opencode"
|
| 226 |
+
return install_dir / "node_modules" / ".bin" / name
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def opencode_serve_command(binary: str | Path, port: int = OPENCODE_PORT) -> list[str]:
|
| 230 |
+
return [str(binary), "serve", "--hostname", "127.0.0.1", "--port", str(port)]
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def bridge_command(port: int = BRIDGE_PORT) -> list[str]:
|
| 234 |
+
import sys
|
| 235 |
+
|
| 236 |
+
entrypoint = Path(__file__).resolve().parent / "naldo_opencode_bridge.py"
|
| 237 |
+
return [sys.executable, "-u", str(entrypoint)]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _http_json(
|
| 241 |
+
url: str,
|
| 242 |
+
method: str = "GET",
|
| 243 |
+
body: dict[str, Any] | None = None,
|
| 244 |
+
timeout: float = 10.0,
|
| 245 |
+
) -> Any:
|
| 246 |
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
| 247 |
+
request = urllib.request.Request(url, data=data, method=method)
|
| 248 |
+
if data is not None:
|
| 249 |
+
request.add_header("Content-Type", "application/json")
|
| 250 |
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 251 |
+
raw = response.read().decode("utf-8", errors="replace")
|
| 252 |
+
try:
|
| 253 |
+
return json.loads(raw) if raw else {}
|
| 254 |
+
except ValueError:
|
| 255 |
+
return {"raw": raw}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def check_opencode_health(base: str = OPENCODE_BASE, timeout: float = 5.0) -> dict[str, Any]:
|
| 259 |
+
try:
|
| 260 |
+
payload = _http_json(base.rstrip("/") + "/global/health", timeout=timeout)
|
| 261 |
+
ready = bool(isinstance(payload, dict) and payload.get("healthy") is True)
|
| 262 |
+
return {
|
| 263 |
+
"ok": ready,
|
| 264 |
+
"ready": ready,
|
| 265 |
+
"detail": payload if isinstance(payload, dict) else {"raw": str(payload)[:200]},
|
| 266 |
+
}
|
| 267 |
+
except Exception as exc:
|
| 268 |
+
return {"ok": False, "ready": False, "error": f"{type(exc).__name__}: {str(exc)[:160]}"}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def check_bridge_health(base: str = BRIDGE_BASE, timeout: float = 5.0) -> dict[str, Any]:
|
| 272 |
+
try:
|
| 273 |
+
root = base.rstrip("/")
|
| 274 |
+
health_url = root + "/health" if root.endswith("/v1") else root + "/health"
|
| 275 |
+
# BRIDGE_BASE ends with /v1; health lives at /health (no /v1 prefix).
|
| 276 |
+
if health_url.endswith("/v1/health"):
|
| 277 |
+
health_url = health_url[: -len("/v1/health")] + "/health"
|
| 278 |
+
payload = _http_json(health_url, timeout=timeout)
|
| 279 |
+
ready = bool(isinstance(payload, dict) and payload.get("ready"))
|
| 280 |
+
return {"ok": True, "ready": ready, "detail": payload if isinstance(payload, dict) else {}}
|
| 281 |
+
except Exception as exc:
|
| 282 |
+
return {"ok": False, "ready": False, "error": f"{type(exc).__name__}: {str(exc)[:160]}"}
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def opencode_subprocess_env(
|
| 286 |
+
base_env: dict[str, str] | None = None,
|
| 287 |
+
home_dir: Path = OPENCODE_HOME_DIR,
|
| 288 |
+
) -> dict[str, str]:
|
| 289 |
+
"""Build an isolated child environment without inheriting OpenCode auth."""
|
| 290 |
+
env = dict(base_env if base_env is not None else os.environ)
|
| 291 |
+
config_dir = home_dir / "config"
|
| 292 |
+
data_dir = home_dir / "data"
|
| 293 |
+
for directory in (home_dir, config_dir, data_dir, OPENCODE_SESSION_DIR):
|
| 294 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 295 |
+
env.pop("OPENCODE_API_KEY", None)
|
| 296 |
+
env["HOME"] = str(home_dir)
|
| 297 |
+
env["XDG_CONFIG_HOME"] = str(config_dir)
|
| 298 |
+
env["XDG_DATA_HOME"] = str(data_dir)
|
| 299 |
+
return env
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _probe_opencode(binary: Path, timeout: int) -> str:
|
| 303 |
+
try:
|
| 304 |
+
proc = subprocess.run(
|
| 305 |
+
[str(binary), "--version"],
|
| 306 |
+
stdout=subprocess.PIPE,
|
| 307 |
+
stderr=subprocess.STDOUT,
|
| 308 |
+
text=True,
|
| 309 |
+
timeout=timeout,
|
| 310 |
+
check=False,
|
| 311 |
+
)
|
| 312 |
+
except (OSError, subprocess.SubprocessError):
|
| 313 |
+
return ""
|
| 314 |
+
return (proc.stdout or "").strip() if proc.returncode == 0 else ""
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def ensure_opencode_installed(
|
| 318 |
+
install_dir: Path = OPENCODE_INSTALL_DIR,
|
| 319 |
+
timeout: int = 60,
|
| 320 |
+
) -> tuple[str, str]:
|
| 321 |
+
"""Install the pinned CLI locally; never require a global npm prefix/root."""
|
| 322 |
+
install_dir.mkdir(parents=True, exist_ok=True)
|
| 323 |
+
binary = opencode_binary_path(install_dir)
|
| 324 |
+
reported = _probe_opencode(binary, timeout)
|
| 325 |
+
if reported == OPENCODE_VERSION:
|
| 326 |
+
return str(binary), reported
|
| 327 |
+
|
| 328 |
+
npm = "npm.cmd" if os.name == "nt" else "npm"
|
| 329 |
+
proc = subprocess.run(
|
| 330 |
+
[
|
| 331 |
+
npm, "install", "--no-audit", "--no-fund",
|
| 332 |
+
"--prefix", str(install_dir), OPENCODE_NPM_SPEC,
|
| 333 |
+
],
|
| 334 |
+
stdout=subprocess.PIPE,
|
| 335 |
+
stderr=subprocess.STDOUT,
|
| 336 |
+
text=True,
|
| 337 |
+
timeout=600,
|
| 338 |
+
check=False,
|
| 339 |
+
)
|
| 340 |
+
output = proc.stdout or ""
|
| 341 |
+
if proc.returncode != 0:
|
| 342 |
+
raise RuntimeError(f"opencode local install failed rc={proc.returncode}: {output[-2000:]}")
|
| 343 |
+
reported = _probe_opencode(binary, timeout)
|
| 344 |
+
if reported != OPENCODE_VERSION:
|
| 345 |
+
raise RuntimeError(
|
| 346 |
+
f"opencode CLI version mismatch after local install: expected={OPENCODE_VERSION!r} got={reported!r}"
|
| 347 |
+
)
|
| 348 |
+
return str(binary), reported
|
naldo_opencode_bridge.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI-compatible bridge for Naldo_Bot backed exclusively by OpenCode.
|
| 2 |
+
|
| 3 |
+
Listens on 127.0.0.1 only. Translates POST /v1/chat/completions into the real
|
| 4 |
+
OpenCode session API discovered via /doc:
|
| 5 |
+
|
| 6 |
+
- POST /session -> create session (body may carry title)
|
| 7 |
+
- POST /session/{sessionID}/message -> send prompt, wait for reply
|
| 8 |
+
- GET /global/health -> opencode health
|
| 9 |
+
|
| 10 |
+
Each inference creates a fresh session (isolated per Voyager call) and sends
|
| 11 |
+
``tools: {}`` on the message. OpenCode 1.18.33 was verified to return no tool
|
| 12 |
+
parts and perform no requested bash side effect in this mode. Custom deny-all
|
| 13 |
+
session permissions are not used because Zen free-tier rejects them with 403.
|
| 14 |
+
Only assistant ``text`` parts are returned.
|
| 15 |
+
|
| 16 |
+
Temperature: OpenCode session API exposes no temperature field, so Voyager's
|
| 17 |
+
requested temperature is recorded and logged, never forwarded. See
|
| 18 |
+
naldo_opencode.TEMPERATURE_NOTE.
|
| 19 |
+
|
| 20 |
+
Embeddings (SkillManager/QA) are NOT served here; /v1/embeddings proxies to
|
| 21 |
+
the existing local 7860 endpoint to preserve upstream behavior.
|
| 22 |
+
|
| 23 |
+
No OPENCODE_API_KEY is used. No credential file is read or copied. Logs never
|
| 24 |
+
include prompts, completions, or credentials.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
import threading
|
| 32 |
+
import time
|
| 33 |
+
import urllib.request
|
| 34 |
+
import uuid
|
| 35 |
+
from typing import Any
|
| 36 |
+
|
| 37 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 38 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 39 |
+
from starlette.concurrency import run_in_threadpool
|
| 40 |
+
|
| 41 |
+
from naldo_opencode import (
|
| 42 |
+
ALLOWED_MODELS,
|
| 43 |
+
BRIDGE_PORT,
|
| 44 |
+
FALLBACK_MODELS,
|
| 45 |
+
LOCAL_EMBED_BASE,
|
| 46 |
+
OPENCODE_BASE,
|
| 47 |
+
OPENCODE_PROVIDER,
|
| 48 |
+
OPENCODE_SESSION_DIR,
|
| 49 |
+
PRIMARY_MODEL,
|
| 50 |
+
TEMPERATURE_NOTE,
|
| 51 |
+
build_opencode_prompt,
|
| 52 |
+
canonical_model_id,
|
| 53 |
+
extract_assistant_text,
|
| 54 |
+
is_allowed_model,
|
| 55 |
+
opencode_message_body,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
OPENCODE = OPENCODE_BASE.rstrip("/")
|
| 59 |
+
EMBED_BASE = LOCAL_EMBED_BASE.rstrip("/")
|
| 60 |
+
PORT = int(os.environ.get("NALDO_BRIDGE_PORT", str(BRIDGE_PORT)))
|
| 61 |
+
REQUEST_TIMEOUT = float(os.environ.get("NALDO_BRIDGE_TIMEOUT", "240"))
|
| 62 |
+
SESSION_TIMEOUT = float(os.environ.get("NALDO_BRIDGE_SESSION_TIMEOUT", "15"))
|
| 63 |
+
|
| 64 |
+
STATE_LOCK = threading.Lock()
|
| 65 |
+
state: dict[str, Any] = {
|
| 66 |
+
"calls": 0,
|
| 67 |
+
"failures": 0,
|
| 68 |
+
"fallbacks_used": 0,
|
| 69 |
+
"last_model": None,
|
| 70 |
+
"last_fallback": None,
|
| 71 |
+
"last_elapsed_s": None,
|
| 72 |
+
"prompt_tokens": 0,
|
| 73 |
+
"completion_tokens": 0,
|
| 74 |
+
"last_error": None,
|
| 75 |
+
"last_requested_temperature": None,
|
| 76 |
+
"last_requested_max_tokens": None,
|
| 77 |
+
"effective_temperature": TEMPERATURE_NOTE,
|
| 78 |
+
"opencode_base": OPENCODE,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _http_json(
|
| 83 |
+
url: str,
|
| 84 |
+
method: str = "GET",
|
| 85 |
+
body: dict[str, Any] | None = None,
|
| 86 |
+
timeout: float = 10.0,
|
| 87 |
+
retries: int = 2,
|
| 88 |
+
) -> Any:
|
| 89 |
+
last: Exception | None = None
|
| 90 |
+
for attempt in range(retries + 1):
|
| 91 |
+
try:
|
| 92 |
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
| 93 |
+
request = urllib.request.Request(url, data=data, method=method)
|
| 94 |
+
if data is not None:
|
| 95 |
+
request.add_header("Content-Type", "application/json")
|
| 96 |
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 97 |
+
raw = response.read().decode("utf-8", errors="replace")
|
| 98 |
+
return json.loads(raw) if raw else {}
|
| 99 |
+
except Exception as exc: # noqa: BLE001 - mapped to HTTP below
|
| 100 |
+
last = exc
|
| 101 |
+
if attempt < retries:
|
| 102 |
+
time.sleep(min(2.0 * (attempt + 1), 5.0))
|
| 103 |
+
raise RuntimeError(f"{type(last).__name__}: {str(last)[:200]}" if last else "request failed")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _create_session(model_id: str, timeout: float = SESSION_TIMEOUT) -> str:
|
| 107 |
+
model = canonical_model_id(model_id)
|
| 108 |
+
if model not in ALLOWED_MODELS:
|
| 109 |
+
raise RuntimeError(f"refusing non-OpenCode/free session model: {model_id}")
|
| 110 |
+
OPENCODE_SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
session_body = {
|
| 112 |
+
"title": "naldo-voyager",
|
| 113 |
+
"model": {"id": model, "providerID": OPENCODE_PROVIDER},
|
| 114 |
+
"location": {"directory": str(OPENCODE_SESSION_DIR)},
|
| 115 |
+
}
|
| 116 |
+
payload = _http_json(
|
| 117 |
+
OPENCODE + "/session", method="POST", body=session_body, timeout=timeout, retries=0
|
| 118 |
+
)
|
| 119 |
+
session_id = ""
|
| 120 |
+
if isinstance(payload, dict):
|
| 121 |
+
session_id = str(payload.get("id") or payload.get("sessionID") or payload.get("session_id") or "")
|
| 122 |
+
info = payload.get("info")
|
| 123 |
+
if not session_id and isinstance(info, dict):
|
| 124 |
+
session_id = str(info.get("id") or "")
|
| 125 |
+
if not session_id:
|
| 126 |
+
raise RuntimeError("OpenCode session creation returned no id")
|
| 127 |
+
return session_id
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _send_message(session_id: str, model_id: str, system: str, prompt: str) -> str:
|
| 131 |
+
body = opencode_message_body(model_id, system, prompt)
|
| 132 |
+
url = OPENCODE + f"/session/{session_id}/message"
|
| 133 |
+
try:
|
| 134 |
+
# Never retry the same generation POST: a loopback timeout can mean the
|
| 135 |
+
# first request is still running, and retrying would duplicate the turn.
|
| 136 |
+
result = _http_json(url, method="POST", body=body, timeout=REQUEST_TIMEOUT, retries=0)
|
| 137 |
+
finally:
|
| 138 |
+
try:
|
| 139 |
+
_http_json(OPENCODE + f"/session/{session_id}", method="DELETE", timeout=5.0)
|
| 140 |
+
except Exception:
|
| 141 |
+
pass
|
| 142 |
+
text = extract_assistant_text(result)
|
| 143 |
+
if not text:
|
| 144 |
+
raise RuntimeError("OpenCode returned no assistant text parts")
|
| 145 |
+
return text
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _candidate_models(requested: str) -> list[str]:
|
| 149 |
+
ordered: list[str] = []
|
| 150 |
+
bare_requested = canonical_model_id(requested)
|
| 151 |
+
if bare_requested in ALLOWED_MODELS:
|
| 152 |
+
ordered.append(bare_requested)
|
| 153 |
+
for model in FALLBACK_MODELS:
|
| 154 |
+
if model not in ordered:
|
| 155 |
+
ordered.append(model)
|
| 156 |
+
return ordered
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _chat_payload(body: dict[str, Any]) -> dict[str, Any]:
|
| 160 |
+
requested_model = str(body.get("model") or PRIMARY_MODEL)
|
| 161 |
+
if not is_allowed_model(requested_model):
|
| 162 |
+
raise ValueError(f"Unknown model for Naldo OpenCode bridge: {requested_model}")
|
| 163 |
+
requested_temp = body.get("temperature")
|
| 164 |
+
requested_max_tokens = body.get("max_tokens", body.get("max_completion_tokens"))
|
| 165 |
+
with STATE_LOCK:
|
| 166 |
+
state["last_requested_temperature"] = requested_temp
|
| 167 |
+
state["last_requested_max_tokens"] = requested_max_tokens
|
| 168 |
+
state["effective_temperature"] = TEMPERATURE_NOTE
|
| 169 |
+
if isinstance(requested_temp, (int, float)) and float(requested_temp) != 0.0:
|
| 170 |
+
# Voyager normally sends temperature 0; OpenCode session API has no
|
| 171 |
+
# temperature parameter, so record drift without inventing support.
|
| 172 |
+
print(
|
| 173 |
+
f"[naldo-bridge] requested temperature={requested_temp}; {TEMPERATURE_NOTE}",
|
| 174 |
+
flush=True,
|
| 175 |
+
)
|
| 176 |
+
# max_tokens is also not supported by the OpenCode session endpoint. Keep
|
| 177 |
+
# it only as telemetry: never character-truncate JavaScript/JSON responses.
|
| 178 |
+
rendered = build_opencode_prompt(body.get("messages") or [])
|
| 179 |
+
|
| 180 |
+
last_error = "OpenCode inference failed"
|
| 181 |
+
tried: list[str] = []
|
| 182 |
+
for model_id in _candidate_models(requested_model):
|
| 183 |
+
tried.append(model_id)
|
| 184 |
+
started = time.monotonic()
|
| 185 |
+
try:
|
| 186 |
+
session_id = _create_session(model_id)
|
| 187 |
+
answer = _send_message(session_id, model_id, rendered["system"], rendered["prompt"])
|
| 188 |
+
except Exception as exc: # noqa: BLE001 - retried across free models
|
| 189 |
+
last_error = f"{type(exc).__name__}: {str(exc)[:200]}"
|
| 190 |
+
continue
|
| 191 |
+
elapsed = round(time.monotonic() - started, 2)
|
| 192 |
+
with STATE_LOCK:
|
| 193 |
+
state["calls"] += 1
|
| 194 |
+
state["last_model"] = model_id
|
| 195 |
+
state["last_elapsed_s"] = elapsed
|
| 196 |
+
state["last_error"] = None
|
| 197 |
+
if model_id != canonical_model_id(requested_model):
|
| 198 |
+
state["fallbacks_used"] += 1
|
| 199 |
+
state["last_fallback"] = model_id
|
| 200 |
+
else:
|
| 201 |
+
state["last_fallback"] = None
|
| 202 |
+
return {
|
| 203 |
+
"id": "chatcmpl-" + uuid.uuid4().hex[:16],
|
| 204 |
+
"object": "chat.completion",
|
| 205 |
+
"created": int(time.time()),
|
| 206 |
+
"model": canonical_model_id(requested_model),
|
| 207 |
+
"choices": [
|
| 208 |
+
{"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}
|
| 209 |
+
],
|
| 210 |
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
| 211 |
+
"backend": "opencode-bridge",
|
| 212 |
+
"provider": OPENCODE_PROVIDER,
|
| 213 |
+
}
|
| 214 |
+
with STATE_LOCK:
|
| 215 |
+
state["failures"] += 1
|
| 216 |
+
state["last_error"] = f"tried={tried} last={last_error}"
|
| 217 |
+
raise RuntimeError(f"All OpenCode free models failed (tried={tried}): {last_error}")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
api = FastAPI(title="Naldo OpenCode Bridge", version="1.0.0")
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@api.get("/health")
|
| 224 |
+
def health() -> dict[str, Any]:
|
| 225 |
+
try:
|
| 226 |
+
upstream = _http_json(OPENCODE + "/global/health", timeout=5.0, retries=0)
|
| 227 |
+
upstream_ok = bool(isinstance(upstream, dict) and upstream.get("healthy") is True)
|
| 228 |
+
except Exception as exc: # noqa: BLE001 - health must not raise
|
| 229 |
+
upstream = {"error": f"{type(exc).__name__}"}
|
| 230 |
+
upstream_ok = False
|
| 231 |
+
with STATE_LOCK:
|
| 232 |
+
snapshot = dict(state)
|
| 233 |
+
return {
|
| 234 |
+
"ready": upstream_ok,
|
| 235 |
+
"bridge": "naldo-opencode",
|
| 236 |
+
"provider": OPENCODE_PROVIDER,
|
| 237 |
+
"primary_model": PRIMARY_MODEL,
|
| 238 |
+
"fallback_models": list(FALLBACK_MODELS),
|
| 239 |
+
"opencode_base": OPENCODE,
|
| 240 |
+
"opencode": {"ok": upstream_ok, "detail": "ok" if upstream_ok else upstream},
|
| 241 |
+
"temperature": TEMPERATURE_NOTE,
|
| 242 |
+
**{k: v for k, v in snapshot.items() if k != "opencode_base"},
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@api.get("/v1/models")
|
| 247 |
+
def models() -> dict[str, Any]:
|
| 248 |
+
now = int(time.time())
|
| 249 |
+
return {
|
| 250 |
+
"object": "list",
|
| 251 |
+
"data": [
|
| 252 |
+
{"id": model, "object": "model", "owned_by": OPENCODE_PROVIDER}
|
| 253 |
+
for model in FALLBACK_MODELS
|
| 254 |
+
]
|
| 255 |
+
+ [
|
| 256 |
+
{"id": f"{OPENCODE_PROVIDER}/{model}", "object": "model", "owned_by": OPENCODE_PROVIDER}
|
| 257 |
+
for model in FALLBACK_MODELS
|
| 258 |
+
],
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@api.post("/v1/chat/completions")
|
| 263 |
+
async def chat(request: Request) -> Any:
|
| 264 |
+
try:
|
| 265 |
+
body = await request.json()
|
| 266 |
+
except Exception as exc:
|
| 267 |
+
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(exc)[:160]}") from exc
|
| 268 |
+
if not isinstance(body.get("messages"), list):
|
| 269 |
+
raise HTTPException(status_code=400, detail="messages must be a list")
|
| 270 |
+
requested_model = str(body.get("model") or PRIMARY_MODEL)
|
| 271 |
+
if not is_allowed_model(requested_model):
|
| 272 |
+
raise HTTPException(status_code=404, detail=f"Unknown model: {requested_model}")
|
| 273 |
+
try:
|
| 274 |
+
payload = await run_in_threadpool(_chat_payload, body)
|
| 275 |
+
except ValueError as exc:
|
| 276 |
+
raise HTTPException(status_code=404, detail=str(exc)[:200]) from None
|
| 277 |
+
except RuntimeError as exc:
|
| 278 |
+
raise HTTPException(status_code=503, detail=str(exc)[:800]) from None
|
| 279 |
+
if not body.get("stream"):
|
| 280 |
+
return JSONResponse(payload)
|
| 281 |
+
content = payload["choices"][0]["message"]["content"]
|
| 282 |
+
chunk = {
|
| 283 |
+
"id": payload["id"],
|
| 284 |
+
"object": "chat.completion.chunk",
|
| 285 |
+
"created": payload["created"],
|
| 286 |
+
"model": payload["model"],
|
| 287 |
+
"choices": [{"index": 0, "delta": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
async def events(): # pragma: no cover - streaming passthrough
|
| 291 |
+
yield "data: " + json.dumps(chunk, ensure_ascii=False) + "\n\n"
|
| 292 |
+
yield "data: [DONE]\n\n"
|
| 293 |
+
|
| 294 |
+
return StreamingResponse(events(), media_type="text/event-stream")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
@api.post("/v1/embeddings")
|
| 298 |
+
async def embeddings(request: Request) -> Any:
|
| 299 |
+
"""Preserve local embeddings for SkillManager/QA via the 7860 endpoint."""
|
| 300 |
+
raw = await request.body()
|
| 301 |
+
forward = urllib.request.Request(
|
| 302 |
+
EMBED_BASE.rstrip("/") + "/embeddings", data=raw, method="POST"
|
| 303 |
+
)
|
| 304 |
+
forward.add_header("Content-Type", "application/json")
|
| 305 |
+
api_key = os.environ.get("API_KEY", "").strip()
|
| 306 |
+
if api_key:
|
| 307 |
+
forward.add_header("Authorization", f"Bearer {api_key}")
|
| 308 |
+
try:
|
| 309 |
+
with urllib.request.urlopen(forward, timeout=120) as response:
|
| 310 |
+
return JSONResponse(json.loads(response.read().decode("utf-8")))
|
| 311 |
+
except Exception as exc: # noqa: BLE001 - mapped to 503 without payload
|
| 312 |
+
raise HTTPException(
|
| 313 |
+
status_code=503, detail=f"embedding backend unavailable: {type(exc).__name__}"
|
| 314 |
+
) from None
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
if __name__ == "__main__":
|
| 318 |
+
import uvicorn
|
| 319 |
+
|
| 320 |
+
# Loopback only: never expose OpenCode or this bridge externally.
|
| 321 |
+
uvicorn.run(api, host="127.0.0.1", port=PORT, log_level="warning")
|
tests/test_agent.py
CHANGED
|
@@ -17,8 +17,8 @@ import urllib.request
|
|
| 17 |
|
| 18 |
import tiktoken
|
| 19 |
|
| 20 |
-
BASE = os.environ.get("BASE", "https://erinaldordso-
|
| 21 |
-
MODEL = os.environ.get("MODEL", "
|
| 22 |
APIKEY = os.environ.get("APIKEY", "")
|
| 23 |
PASS, FAIL = "PASS", "FAIL"
|
| 24 |
results = []
|
|
@@ -81,7 +81,7 @@ def main():
|
|
| 81 |
|
| 82 |
st, models = req("GET", "/models")
|
| 83 |
ids = [m.get("id", "") for m in models.get("data", [])]
|
| 84 |
-
check("models", st == 200 and any(MODEL in i or "
|
| 85 |
|
| 86 |
st, emb = req("POST", "/embeddings", {"model": "text-embedding-ada-002", "input": ["mine wood", "craft pickaxe"]})
|
| 87 |
vecs = emb.get("data", [])
|
|
|
|
| 17 |
|
| 18 |
import tiktoken
|
| 19 |
|
| 20 |
+
BASE = os.environ.get("BASE", "https://erinaldordso-minecraft-friends-ai-zerogpu.hf.space/v1").rstrip("/")
|
| 21 |
+
MODEL = os.environ.get("MODEL", "granite-4.0-h-tiny")
|
| 22 |
APIKEY = os.environ.get("APIKEY", "")
|
| 23 |
PASS, FAIL = "PASS", "FAIL"
|
| 24 |
results = []
|
|
|
|
| 81 |
|
| 82 |
st, models = req("GET", "/models")
|
| 83 |
ids = [m.get("id", "") for m in models.get("data", [])]
|
| 84 |
+
check("models", st == 200 and any(MODEL in i or "granite" in i.lower() for i in ids), f"ids={ids[:4]}")
|
| 85 |
|
| 86 |
st, emb = req("POST", "/embeddings", {"model": "text-embedding-ada-002", "input": ["mine wood", "craft pickaxe"]})
|
| 87 |
vecs = emb.get("data", [])
|
tests/test_autonomy_stability.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stability of autonomous operation: bounded status reads, restart backoff,
|
| 2 |
+
and a single configurable local LLM path for Naldo_Bot.
|
| 3 |
+
|
| 4 |
+
Offline-safe: pure stdlib plus text-level assertions on the deployment files.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from types import SimpleNamespace
|
| 12 |
+
from unittest import mock
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 15 |
+
sys.path.insert(0, str(ROOT))
|
| 16 |
+
|
| 17 |
+
from voyager_stack import restart_backoff_after, tail_lines # noqa: E402
|
| 18 |
+
import gemini_bot_stack as gemini_module # noqa: E402
|
| 19 |
+
import minecraft_stack as mc_stack # noqa: E402
|
| 20 |
+
from minecraft_stack import _tail_lines as mc_tail_lines # noqa: E402
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_tail_lines_matches_full_read_on_small_logs(tmp_path):
|
| 24 |
+
path = tmp_path / "voyager.log"
|
| 25 |
+
expected = [f"line {i} [llm_metric] payload" for i in range(500)]
|
| 26 |
+
path.write_text("\n".join(expected) + "\n", encoding="utf-8")
|
| 27 |
+
assert tail_lines(path) == expected
|
| 28 |
+
assert mc_tail_lines(path, 500) == expected
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_tail_lines_is_bounded_on_long_logs(tmp_path):
|
| 32 |
+
path = tmp_path / "voyager.log"
|
| 33 |
+
total = 60000
|
| 34 |
+
with path.open("w", encoding="utf-8") as fh:
|
| 35 |
+
for i in range(total):
|
| 36 |
+
fh.write(f"line {i}\n")
|
| 37 |
+
window = tail_lines(path, 20000)
|
| 38 |
+
assert len(window) == 20000
|
| 39 |
+
assert window[0] == f"line {total - 20000}"
|
| 40 |
+
assert window[-1] == f"line {total - 1}"
|
| 41 |
+
assert tail_lines(tmp_path / "missing.log") == []
|
| 42 |
+
assert mc_tail_lines(tmp_path / "missing.log") == []
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_restart_backoff_doubles_on_quick_crash_and_resets_when_healthy():
|
| 46 |
+
assert restart_backoff_after(5, 12.0) == 10
|
| 47 |
+
assert restart_backoff_after(60, 30.0) == 120
|
| 48 |
+
assert restart_backoff_after(120, 1.0) == 120
|
| 49 |
+
assert restart_backoff_after(120, 3600.0) == 5
|
| 50 |
+
assert restart_backoff_after(120, 600.0) == 5
|
| 51 |
+
assert restart_backoff_after(10, 599.9) == 20
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_both_bots_share_the_same_backoff_policy():
|
| 55 |
+
assert gemini_module.restart_backoff_after is restart_backoff_after
|
| 56 |
+
assert gemini_module.tail_lines is tail_lines
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_naldo_uses_only_the_configurable_local_endpoint():
|
| 60 |
+
stack = (ROOT / "voyager_stack.py").read_text(encoding="utf-8")
|
| 61 |
+
# Naldo generative brain is the loopback OpenCode bridge; 7860 stays only
|
| 62 |
+
# for local embeddings compatibility.
|
| 63 |
+
assert "NALDO_BRIDGE_BASE" in stack
|
| 64 |
+
assert "NALDO_BRAIN_PROFILE" in stack
|
| 65 |
+
assert "NALDO_PRIMARY_MODEL" in stack
|
| 66 |
+
assert "naldo-opencode" in stack or "NALDO_BRAIN_PROFILE" in stack
|
| 67 |
+
assert "granite-4.0-h-tiny" not in stack
|
| 68 |
+
runner = (ROOT / "voyager_runner.py").read_text(encoding="utf-8")
|
| 69 |
+
assert "http://127.0.0.1:7864/v1" in runner
|
| 70 |
+
assert "NALDO_BRAIN_PROFILE" in runner
|
| 71 |
+
assert "from naldo_opencode import" in runner
|
| 72 |
+
assert "naldo-granite" not in runner
|
| 73 |
+
assert "voyager.learn(reset_env=False)" in runner
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_no_new_direct_llm_sdk_dependency():
|
| 77 |
+
requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8").lower()
|
| 78 |
+
assert "google-generativeai" not in requirements
|
| 79 |
+
assert "google-genai" not in requirements
|
| 80 |
+
assert "anthropic" not in requirements
|
| 81 |
+
assert "openai==0.28.1" in requirements
|
| 82 |
+
assert "llama-cpp-python" in requirements
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_local_endpoint_stays_cpu_only():
|
| 86 |
+
app = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 87 |
+
assert "n_gpu_layers=0" in app
|
| 88 |
+
assert 'os.environ["CUDA_VISIBLE_DEVICES"] = ""' in app
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_acceptance_script_defaults_match_this_deployment():
|
| 92 |
+
agent = (ROOT / "tests" / "test_agent.py").read_text(encoding="utf-8")
|
| 93 |
+
app = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 94 |
+
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
| 95 |
+
base = re.search(r'BASE = os.environ.get\("BASE", "(.*?)"\)', agent).group(1)
|
| 96 |
+
model = re.search(r'MODEL = os.environ.get\("MODEL", "(.*?)"\)', agent).group(1)
|
| 97 |
+
assert base in readme, "acceptance BASE default must be the documented Space URL"
|
| 98 |
+
assert base.endswith("/v1")
|
| 99 |
+
assert model == "granite-4.0-h-tiny"
|
| 100 |
+
assert f'"{model}"' in app or f"'{model}'" in app or model in app
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_boot_counters_survive_tail_window_and_reset_on_new_boot(tmp_path):
|
| 104 |
+
import inspect
|
| 105 |
+
|
| 106 |
+
# Wiring: every new minecraft boot must reset the counters.
|
| 107 |
+
assert "_reset_boot_counters" in inspect.getsource(
|
| 108 |
+
mc_stack.MinecraftStack._start_fabric
|
| 109 |
+
)
|
| 110 |
+
stack = mc_stack.MinecraftStack()
|
| 111 |
+
empty_log = tmp_path / "stack.log"
|
| 112 |
+
empty_log.write_text("log line without markers\n", encoding="utf-8")
|
| 113 |
+
with mock.patch.object(mc_stack, "_log", lambda *args, **kwargs: None), \
|
| 114 |
+
mock.patch.object(mc_stack, "LOG_PATH", empty_log):
|
| 115 |
+
stack._pump("minecraft", SimpleNamespace(stdout=[
|
| 116 |
+
"Starting minecraft server version 1.19\n",
|
| 117 |
+
"Failed to save player data for Naldo_Bot\n",
|
| 118 |
+
'java.lang.NullPointerException: Cannot read field "respawnDimension" because "player" is null\n',
|
| 119 |
+
"Failed to save player data for IffyBinkie6134\n",
|
| 120 |
+
'Done (1.234s)! For help, type "help"\n',
|
| 121 |
+
]))
|
| 122 |
+
assert stack.failed_saves_since_boot == 2
|
| 123 |
+
assert stack.respawn_npe_since_boot == 1
|
| 124 |
+
|
| 125 |
+
stack._pump("viaproxy", SimpleNamespace(stdout=[
|
| 126 |
+
"Failed to save player data for x\n",
|
| 127 |
+
]))
|
| 128 |
+
assert stack.failed_saves_since_boot == 2
|
| 129 |
+
assert stack.respawn_npe_since_boot == 1
|
| 130 |
+
|
| 131 |
+
# The tail window holds none of those lines, yet the counters behind
|
| 132 |
+
# the *_since_boot contract must still report them.
|
| 133 |
+
report = stack.status()
|
| 134 |
+
assert report["player_save"]["failed_saves_since_boot"] == 2
|
| 135 |
+
assert report["player_save"]["respawn_npe_since_boot"] == 1
|
| 136 |
+
|
| 137 |
+
stack._reset_boot_counters()
|
| 138 |
+
report = stack.status()
|
| 139 |
+
assert report["player_save"]["failed_saves_since_boot"] == 0
|
| 140 |
+
assert report["player_save"]["respawn_npe_since_boot"] == 0
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
import tempfile
|
| 145 |
+
|
| 146 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 147 |
+
tmp_path = Path(tmp)
|
| 148 |
+
test_tail_lines_matches_full_read_on_small_logs(tmp_path)
|
| 149 |
+
test_tail_lines_is_bounded_on_long_logs(tmp_path)
|
| 150 |
+
test_boot_counters_survive_tail_window_and_reset_on_new_boot(tmp_path)
|
| 151 |
+
test_restart_backoff_doubles_on_quick_crash_and_resets_when_healthy()
|
| 152 |
+
test_both_bots_share_the_same_backoff_policy()
|
| 153 |
+
test_naldo_uses_only_the_configurable_local_endpoint()
|
| 154 |
+
test_no_new_direct_llm_sdk_dependency()
|
| 155 |
+
test_local_endpoint_stays_cpu_only()
|
| 156 |
+
test_acceptance_script_defaults_match_this_deployment()
|
| 157 |
+
print("PASS autonomy stability")
|
tests/test_naldo_opencode.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Naldo_Bot on OpenCode only: offline unit tests.
|
| 2 |
+
|
| 3 |
+
No network, no model, no Minecraft. Pure stdlib plus text-level assertions
|
| 4 |
+
on the deployment files.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import ast
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from types import SimpleNamespace
|
| 13 |
+
from unittest import mock
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 16 |
+
sys.path.insert(0, str(ROOT))
|
| 17 |
+
|
| 18 |
+
import naldo_opencode as brain # noqa: E402
|
| 19 |
+
import naldo_opencode_bridge as bridge_module # noqa: E402
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_primary_and_fallbacks_are_all_opencode_free():
|
| 23 |
+
assert brain.OPENCODE_PROVIDER == "opencode"
|
| 24 |
+
assert brain.PRIMARY_MODEL == "muse-spark-1.3-contributor-free"
|
| 25 |
+
assert brain.FALLBACK_MODELS[0] == brain.PRIMARY_MODEL
|
| 26 |
+
assert set(brain.FALLBACK_MODELS) == {
|
| 27 |
+
"muse-spark-1.3-contributor-free",
|
| 28 |
+
"fledge-alpha-free",
|
| 29 |
+
"nemotron-3-ultra-free",
|
| 30 |
+
}
|
| 31 |
+
for model in brain.FALLBACK_MODELS:
|
| 32 |
+
assert brain.is_allowed_model(model)
|
| 33 |
+
assert brain.is_allowed_model(f"opencode/{model}")
|
| 34 |
+
lowered = model.lower()
|
| 35 |
+
for forbidden in ("granite", "gemini", "gpt", "claude", "anthropic", "qwen"):
|
| 36 |
+
assert forbidden not in lowered
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_no_granite_gemini_fallback_anywhere():
|
| 40 |
+
for name in ("granite", "gemini", "gpt-", "anthropic", "claude"):
|
| 41 |
+
assert name not in {m.lower() for m in brain.FALLBACK_MODELS}
|
| 42 |
+
stack = (ROOT / "voyager_stack.py").read_text(encoding="utf-8")
|
| 43 |
+
assert "granite-4.0-h-tiny" not in stack, "Naldo stack must not reference Granite anymore"
|
| 44 |
+
bridge = (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
|
| 45 |
+
lowered = bridge.lower()
|
| 46 |
+
for forbidden in ("granite", "gemini", "anthropic"):
|
| 47 |
+
assert forbidden not in lowered, f"bridge leaked {forbidden}"
|
| 48 |
+
runner = (ROOT / "voyager_runner.py").read_text(encoding="utf-8")
|
| 49 |
+
# Gemini_Bot's own lock stays; Naldo's lock must not accept granite.
|
| 50 |
+
assert "naldo-granite" not in runner
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_brain_lock_requires_opencode_profile_base_and_models():
|
| 54 |
+
runner = (ROOT / "voyager_runner.py").read_text(encoding="utf-8")
|
| 55 |
+
assert "NALDO_BRAIN_PROFILE" in runner or "naldo-opencode" in runner
|
| 56 |
+
assert "http://127.0.0.1:7864/v1" in runner
|
| 57 |
+
assert "muse-spark-1.3-contributor-free" in runner
|
| 58 |
+
assert "_naldo_model_allowed" in runner
|
| 59 |
+
# Rejected models: granite/gemini must not pass the predicate.
|
| 60 |
+
assert not brain.is_allowed_model("granite-4.0-h-tiny")
|
| 61 |
+
assert not brain.is_allowed_model("gemini-3.5-flash-lite")
|
| 62 |
+
assert not brain.is_allowed_model("gpt-4o")
|
| 63 |
+
assert brain.is_allowed_model("fledge-alpha-free")
|
| 64 |
+
assert brain.is_allowed_model("nemotron-3-ultra-free")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_bridge_formats_messages_without_confusing_roles():
|
| 68 |
+
messages = [
|
| 69 |
+
{"role": "system", "content": "You are a planner."},
|
| 70 |
+
{"role": "user", "content": "Do X"},
|
| 71 |
+
{"role": "assistant", "content": "Plan Y"},
|
| 72 |
+
{"role": "tool", "content": "tool output Z"},
|
| 73 |
+
{"role": "user", "content": "Now?", "tool_calls": [{"id": "1", "function": {"name": "f", "arguments": "{}"}}]},
|
| 74 |
+
]
|
| 75 |
+
rendered = brain.build_opencode_prompt(messages)
|
| 76 |
+
assert "[SYSTEM]" in rendered["prompt"]
|
| 77 |
+
assert "[USER]" in rendered["prompt"]
|
| 78 |
+
assert "[ASSISTANT]" in rendered["prompt"]
|
| 79 |
+
assert "[TOOL]" in rendered["prompt"]
|
| 80 |
+
assert "You are a planner." in rendered["system"]
|
| 81 |
+
assert "TOOL_CALLS" in rendered["prompt"]
|
| 82 |
+
assert "text generator, not a file/shell executor" in rendered["prompt"]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_bridge_extracts_only_text_parts():
|
| 86 |
+
payload = {
|
| 87 |
+
"info": {"role": "assistant"},
|
| 88 |
+
"parts": [
|
| 89 |
+
{"type": "reasoning", "thinking": "secret plan"},
|
| 90 |
+
{"type": "text", "text": "hello "},
|
| 91 |
+
{"type": "tool", "name": "bash", "input": "rm -rf /"},
|
| 92 |
+
{"type": "text", "text": "world"},
|
| 93 |
+
],
|
| 94 |
+
}
|
| 95 |
+
assert brain.extract_assistant_text(payload) == "hello world"
|
| 96 |
+
assert brain.extract_assistant_text({"parts": []}) == ""
|
| 97 |
+
assert brain.extract_assistant_text({}) == ""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_sessions_use_free_tier_compatible_tool_sandbox(tmp_path):
|
| 101 |
+
captured = {}
|
| 102 |
+
|
| 103 |
+
def fake_http(url, method="GET", body=None, timeout=0, retries=0):
|
| 104 |
+
captured.update({"url": url, "method": method, "body": body})
|
| 105 |
+
return {"id": "ses_test"}
|
| 106 |
+
|
| 107 |
+
with mock.patch.object(bridge_module, "OPENCODE_SESSION_DIR", tmp_path), \
|
| 108 |
+
mock.patch.object(bridge_module, "_http_json", side_effect=fake_http):
|
| 109 |
+
session_id = bridge_module._create_session("muse-spark-1.3-contributor-free")
|
| 110 |
+
|
| 111 |
+
assert session_id == "ses_test"
|
| 112 |
+
assert captured["method"] == "POST"
|
| 113 |
+
assert captured["url"].endswith("/session")
|
| 114 |
+
assert captured["body"]["model"] == {
|
| 115 |
+
"id": "muse-spark-1.3-contributor-free",
|
| 116 |
+
"providerID": "opencode",
|
| 117 |
+
}
|
| 118 |
+
assert captured["body"]["location"] == {"directory": str(tmp_path)}
|
| 119 |
+
assert "permission" not in captured["body"], "custom permissions trigger Zen free-tier 403"
|
| 120 |
+
|
| 121 |
+
message = brain.opencode_message_body("muse-spark-1.3-contributor-free", "sys", "do X")
|
| 122 |
+
assert message["tools"] == {}, "empty tools map is the verified free-tier tool sandbox"
|
| 123 |
+
assert "temperature" not in message
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_loopback_only():
|
| 127 |
+
assert brain.is_loopback_url(brain.OPENCODE_BASE)
|
| 128 |
+
assert brain.is_loopback_url(brain.BRIDGE_BASE)
|
| 129 |
+
assert "0.0.0.0" not in (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
|
| 130 |
+
stack = (ROOT / "voyager_stack.py").read_text(encoding="utf-8")
|
| 131 |
+
assert "opencode_serve_command(opencode_binary" in stack
|
| 132 |
+
assert 'host="127.0.0.1"' in (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
|
| 133 |
+
assert "shell=True" not in (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
|
| 134 |
+
assert "shell=True" not in (ROOT / "naldo_opencode.py").read_text(encoding="utf-8")
|
| 135 |
+
serve = brain.opencode_serve_command("/tmp/opencode-local", 7865)
|
| 136 |
+
assert serve[:3] == ["/tmp/opencode-local", "serve", "--hostname"]
|
| 137 |
+
assert "127.0.0.1" in serve
|
| 138 |
+
assert "0.0.0.0" not in serve
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_no_credential_dependency_or_leak(tmp_path):
|
| 142 |
+
bridge_src = (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
|
| 143 |
+
assert "auth.json" not in bridge_src
|
| 144 |
+
assert 'os.environ.get("OPENCODE_API_KEY"' not in bridge_src
|
| 145 |
+
assert 'os.environ["OPENCODE_API_KEY"]' not in bridge_src
|
| 146 |
+
|
| 147 |
+
base = {"OPENCODE_API_KEY": "must-not-survive", "PATH": "x"}
|
| 148 |
+
env = brain.opencode_subprocess_env(base, tmp_path / "home")
|
| 149 |
+
assert "OPENCODE_API_KEY" not in env
|
| 150 |
+
assert env["PATH"] == "x"
|
| 151 |
+
assert env["HOME"] == str(tmp_path / "home")
|
| 152 |
+
assert env["XDG_CONFIG_HOME"] == str(tmp_path / "home" / "config")
|
| 153 |
+
assert env["XDG_DATA_HOME"] == str(tmp_path / "home" / "data")
|
| 154 |
+
assert base["OPENCODE_API_KEY"] == "must-not-survive", "parent env must not be mutated"
|
| 155 |
+
assert "TEMPERATURE_NOTE" in (ROOT / "naldo_opencode.py").read_text(encoding="utf-8")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_local_opencode_install_is_pinned_and_non_root(tmp_path):
|
| 159 |
+
calls = []
|
| 160 |
+
|
| 161 |
+
def fake_run(args, **kwargs):
|
| 162 |
+
calls.append(list(args))
|
| 163 |
+
return SimpleNamespace(returncode=0, stdout="installed")
|
| 164 |
+
|
| 165 |
+
with mock.patch.object(brain, "_probe_opencode", side_effect=["", brain.OPENCODE_VERSION]), \
|
| 166 |
+
mock.patch.object(brain.subprocess, "run", side_effect=fake_run):
|
| 167 |
+
binary, version = brain.ensure_opencode_installed(tmp_path / "cli")
|
| 168 |
+
|
| 169 |
+
assert version == brain.OPENCODE_VERSION
|
| 170 |
+
assert Path(binary) == brain.opencode_binary_path(tmp_path / "cli")
|
| 171 |
+
install = calls[0]
|
| 172 |
+
assert "-g" not in install and "--global" not in install
|
| 173 |
+
assert "--prefix" in install
|
| 174 |
+
assert install[install.index("--prefix") + 1] == str(tmp_path / "cli")
|
| 175 |
+
assert brain.OPENCODE_NPM_SPEC in install
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_max_tokens_is_telemetry_not_output_truncation():
|
| 179 |
+
long_answer = "function build() { return 1; }\n" * 300
|
| 180 |
+
with mock.patch.object(bridge_module, "_create_session", return_value="ses_x"), \
|
| 181 |
+
mock.patch.object(bridge_module, "_send_message", return_value=long_answer):
|
| 182 |
+
result = bridge_module._chat_payload({
|
| 183 |
+
"model": brain.PRIMARY_MODEL,
|
| 184 |
+
"messages": [{"role": "user", "content": "write program"}],
|
| 185 |
+
"temperature": 0,
|
| 186 |
+
"max_tokens": 1,
|
| 187 |
+
})
|
| 188 |
+
assert result["choices"][0]["message"]["content"] == long_answer
|
| 189 |
+
assert bridge_module.state["last_requested_max_tokens"] == 1
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_supervisor_start_stop_contract():
|
| 193 |
+
stack_src = (ROOT / "voyager_stack.py").read_text(encoding="utf-8")
|
| 194 |
+
for marker in (
|
| 195 |
+
"_ensure_naldo_brain",
|
| 196 |
+
"_brain_healthy",
|
| 197 |
+
"_stop_brain",
|
| 198 |
+
"brain_status",
|
| 199 |
+
"_terminate_naldo_child",
|
| 200 |
+
"ensure_opencode_installed",
|
| 201 |
+
"NALDO_BRIDGE_BASE",
|
| 202 |
+
"NALDO_BRAIN_PROFILE",
|
| 203 |
+
"NALDO_PRIMARY_MODEL",
|
| 204 |
+
"opencode_serve_command",
|
| 205 |
+
"opencode_subprocess_env",
|
| 206 |
+
):
|
| 207 |
+
assert marker in stack_src, f"supervisor marker missing: {marker}"
|
| 208 |
+
assert "without Granite fallback" in stack_src or "without granite" in stack_src.lower()
|
| 209 |
+
tree = ast.parse(stack_src)
|
| 210 |
+
methods = set()
|
| 211 |
+
for node in ast.walk(tree):
|
| 212 |
+
if isinstance(node, ast.ClassDef) and node.name == "VoyagerStack":
|
| 213 |
+
for child in node.body:
|
| 214 |
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 215 |
+
methods.add(child.name)
|
| 216 |
+
for required in ("_ensure_naldo_brain", "_brain_healthy", "_stop_brain", "brain_status", "stop", "start_async"):
|
| 217 |
+
assert required in methods
|
| 218 |
+
assert "self.stop_event.wait(7)" in stack_src
|
| 219 |
+
assert "OpenCode/bridge died mid-run; stopping Naldo immediately" in stack_src
|
| 220 |
+
assert "opencode_serve_command(opencode_binary" in stack_src
|
| 221 |
+
|
| 222 |
+
runner_src = (ROOT / "voyager_runner.py").read_text(encoding="utf-8")
|
| 223 |
+
assert "Naldo OpenCode brain module unavailable" in runner_src
|
| 224 |
+
assert "except Exception as exc" in runner_src
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_supervisor_stop_terminates_all_children():
|
| 228 |
+
import voyager_stack as stack_module
|
| 229 |
+
|
| 230 |
+
stack = stack_module.VoyagerStack()
|
| 231 |
+
naldo = SimpleNamespace(poll=lambda: None, terminate=lambda: setattr(naldo, "t", True), kill=lambda: None, wait=lambda timeout=None: 0)
|
| 232 |
+
bridge = SimpleNamespace(poll=lambda: None, terminate=lambda: setattr(bridge, "t", True), kill=lambda: None, wait=lambda timeout=None: 0)
|
| 233 |
+
opencode = SimpleNamespace(poll=lambda: None, terminate=lambda: setattr(opencode, "t", True), kill=lambda: None, wait=lambda timeout=None: 0)
|
| 234 |
+
stack.proc = naldo # type: ignore[assignment]
|
| 235 |
+
stack.bridge_proc = bridge # type: ignore[assignment]
|
| 236 |
+
stack.opencode_proc = opencode # type: ignore[assignment]
|
| 237 |
+
naldo.pid = 111
|
| 238 |
+
bridge.pid = 222
|
| 239 |
+
opencode.pid = 333
|
| 240 |
+
stack.stop_event.set()
|
| 241 |
+
with mock.patch.object(stack_module, "_wait_tcp", return_value=True):
|
| 242 |
+
with mock.patch.object(stack, "brain_status", return_value={"ok": True}):
|
| 243 |
+
with mock.patch.object(stack, "_mineflayer_status", return_value={"ready": False}):
|
| 244 |
+
status = stack.stop()
|
| 245 |
+
assert getattr(naldo, "t", False) is True
|
| 246 |
+
assert getattr(bridge, "t", False) is True
|
| 247 |
+
assert getattr(opencode, "t", False) is True
|
| 248 |
+
assert status["state"] == "stopped"
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_gemini_bot_untouched():
|
| 252 |
+
stack_src = (ROOT / "gemini_bot_stack.py").read_text(encoding="utf-8")
|
| 253 |
+
assert "7863" in stack_src
|
| 254 |
+
assert "gemini-3.5-flash-lite" in stack_src
|
| 255 |
+
assert "opencode" not in stack_src.lower()
|
| 256 |
+
assert "7864" not in stack_src
|
| 257 |
+
assert "7865" not in stack_src
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
test_primary_and_fallbacks_are_all_opencode_free()
|
| 262 |
+
test_no_granite_gemini_fallback_anywhere()
|
| 263 |
+
test_brain_lock_requires_opencode_profile_base_and_models()
|
| 264 |
+
test_bridge_formats_messages_without_confusing_roles()
|
| 265 |
+
test_bridge_extracts_only_text_parts()
|
| 266 |
+
test_sessions_created_with_denied_permissions()
|
| 267 |
+
test_loopback_only()
|
| 268 |
+
test_no_credential_dependency_or_leak()
|
| 269 |
+
test_supervisor_start_stop_contract()
|
| 270 |
+
test_gemini_bot_untouched()
|
| 271 |
+
print("PASS naldo opencode")
|
voyager_runner.py
CHANGED
|
@@ -5,8 +5,6 @@ import json
|
|
| 5 |
import logging
|
| 6 |
import shutil
|
| 7 |
import sys
|
| 8 |
-
import threading
|
| 9 |
-
import time
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
VOYAGER_HOME = Path(os.environ.get("VOYAGER_HOME", "/tmp/voyager-runtime/Voyager"))
|
|
@@ -15,14 +13,10 @@ if str(VOYAGER_HOME) not in sys.path:
|
|
| 15 |
|
| 16 |
from voyager import Voyager # noqa: E402
|
| 17 |
from voyager_verification import (
|
| 18 |
-
quarantine_invalid_skills,
|
| 19 |
-
quarantine_template_skills, task_spec,
|
| 20 |
)
|
| 21 |
from voyager_creative_critic import VerifiedCreativeCritic
|
| 22 |
-
from voyager_runtime_contract import
|
| 23 |
-
ADAPTED_UPSTREAM_FILES, ADDED_UPSTREAM_FILES, attest_original_runtime,
|
| 24 |
-
read_divergence, record_exit as record_exit_report, write_attestation,
|
| 25 |
-
)
|
| 26 |
from voyager_metrics import attach_role_metrics
|
| 27 |
|
| 28 |
|
|
@@ -30,70 +24,47 @@ def env(name: str, default: str) -> str:
|
|
| 30 |
return os.environ.get(name, default)
|
| 31 |
|
| 32 |
|
| 33 |
-
def record_exit(bot_username: str, exc: BaseException) -> None:
|
| 34 |
-
"""Record why this runner died, so the next restart is diagnosable.
|
| 35 |
-
|
| 36 |
-
The logic lives in voyager_runtime_contract so it can be executed by a test:
|
| 37 |
-
this module imports the pinned upstream package, which a unit test should not
|
| 38 |
-
need. Measured need: Gemini_Bot reached restart_count 9 with the only visible
|
| 39 |
-
symptom being "runner=1".
|
| 40 |
-
"""
|
| 41 |
-
record_exit_report(VOYAGER_HOME, bot_username, exc)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
def main() -> None:
|
| 45 |
from voyager_learning import refresh_learning_state, start_learning_watcher
|
| 46 |
# Surface Mineflayer's subprocess logs as well as Python tracebacks.
|
| 47 |
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(levelname)s %(message)s")
|
| 48 |
-
model = env("VOYAGER_MODEL", "
|
| 49 |
-
aux_model = env("VOYAGER_AUX_MODEL", "
|
| 50 |
-
|
| 51 |
-
# http://127.0.0.1:7860/v1 apontaria para este Space, que agora roda só o
|
| 52 |
-
# servidor de Minecraft e responde 503 em /v1/embeddings. O default é o
|
| 53 |
-
# host real; o brain lock abaixo ainda derruba o processo se um deploy
|
| 54 |
-
# apontar para outro provedor.
|
| 55 |
-
base_url = env("VOYAGER_OPENAI_BASE", "https://erinaldordso-mine.hf.space/v1")
|
| 56 |
api_key = env("VOYAGER_API_KEY", "local-voyager")
|
| 57 |
ckpt_dir = env("VOYAGER_CKPT_DIR", "/persist/minecraft-friends/voyager/ckpt-20260927-flat-y64-v1")
|
| 58 |
bot_username = env("MC_USERNAME", "Naldo_Bot")
|
| 59 |
brain_profile = env("VOYAGER_BRAIN_PROFILE", "")
|
| 60 |
-
# Escape hatch. O lock derruba o processo quando a tupla não bate, e o
|
| 61 |
-
# watchdog reinicia em loop — um deploy ruim deixa o bot parado. Com
|
| 62 |
-
# VOYAGER_BRAIN_LOCK=off a checagem inteira é pulada, então dá para subir
|
| 63 |
-
# o bot mesmo com o cérebro remoto fora do ar.
|
| 64 |
-
brain_lock = env("VOYAGER_BRAIN_LOCK", "enforce") != "off"
|
| 65 |
if bot_username == "Naldo_Bot":
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
elif bot_username == "Gemini_Bot":
|
| 81 |
-
|
| 82 |
-
expected = ("gemini-mine-qwen3-coder", "https://erinaldordso-mine.hf.space/v1", "qwen3-coder-30b-a3b-instruct", "granite-4.0-h-tiny")
|
| 83 |
-
else:
|
| 84 |
-
expected = ("gemini-cli", "http://127.0.0.1:7863/v1", "gemini-3.5-flash-lite", "gemini-3.5-flash-lite")
|
| 85 |
else:
|
| 86 |
expected = None
|
| 87 |
-
if expected and
|
| 88 |
raise RuntimeError(
|
| 89 |
f"Brain lock rejected {bot_username}: profile={brain_profile!r}, base={base_url!r}, "
|
| 90 |
-
f"model={model!r}, aux={aux_model!r}
|
| 91 |
-
)
|
| 92 |
-
if expected and not brain_lock:
|
| 93 |
-
print(
|
| 94 |
-
f"[voyager] brain lock DESLIGADO (VOYAGER_BRAIN_LOCK=off); aceitando "
|
| 95 |
-
f"profile={brain_profile!r} base={base_url!r} model={model!r} aux={aux_model!r}",
|
| 96 |
-
flush=True,
|
| 97 |
)
|
| 98 |
ckpt = Path(ckpt_dir)
|
| 99 |
required_resume_defaults = {
|
|
@@ -236,45 +207,20 @@ def main() -> None:
|
|
| 236 |
critic_agent_temperature=0,
|
| 237 |
skill_manager_model_name=aux_model,
|
| 238 |
skill_manager_temperature=0,
|
| 239 |
-
|
| 240 |
-
# upstream FAQ: "It's highly recommended to use GPT-4 ... If you insist
|
| 241 |
-
# on using GPT-3.5, it is essential to configure it with
|
| 242 |
-
# skill_manager_retrieval_top_k <= 2 to reduce the context length of the
|
| 243 |
-
# prompt."
|
| 244 |
-
#
|
| 245 |
-
# The brain is now Qwen3-Coder-30B-A3B with a 32768-token window, so the
|
| 246 |
-
# context pressure the FAQ describes is much lower than it was at 8192.
|
| 247 |
-
# The limit is kept until a benchmark shows that retrieving more skills
|
| 248 |
-
# pays for the extra prefill; it only costs compositional reuse, not
|
| 249 |
-
# correctness. Overridable without a rebuild through VOYAGER_SKILL_TOP_K.
|
| 250 |
-
skill_manager_retrieval_top_k=int(env("VOYAGER_SKILL_TOP_K", "2")),
|
| 251 |
openai_api_request_timeout=int(env("OPENAI_API_REQUEST_TIMEOUT", "240")),
|
| 252 |
ckpt_dir=ckpt_dir,
|
| 253 |
resume=resume,
|
| 254 |
)
|
| 255 |
-
|
| 256 |
-
# certified the upstream CriticAgent and then replaced it, so the report
|
| 257 |
-
# named a decision method that was no longer the one being called. The
|
| 258 |
-
# attestation now resolves the wrapper's upstream delegate and attests that.
|
| 259 |
attach_role_metrics(voyager, bot_username)
|
| 260 |
voyager.critic_agent = VerifiedCreativeCritic(voyager.critic_agent)
|
| 261 |
-
# The boot verified the real divergence against the declared contract before
|
| 262 |
-
# npm install and published the measurement. Carry it into the attestation so
|
| 263 |
-
# `checkout_verified` cannot be read as "the tree is pristine".
|
| 264 |
-
divergence = read_divergence(VOYAGER_HOME)
|
| 265 |
-
attestation = attest_original_runtime(voyager, VOYAGER_HOME)
|
| 266 |
-
attestation["declared_adapted_files"] = dict(ADAPTED_UPSTREAM_FILES)
|
| 267 |
-
attestation["declared_added_files"] = dict(ADDED_UPSTREAM_FILES)
|
| 268 |
-
attestation["measured_divergence"] = divergence
|
| 269 |
write_attestation(env("VOYAGER_ATTESTATION_FILE", ""), attestation, bot_username)
|
| 270 |
print(
|
| 271 |
f"[voyager] verified original engine {attestation['upstream_commit']} for "
|
| 272 |
f"{bot_username}; decision methods attested: "
|
| 273 |
-
f"{', '.join(attestation['decision_methods_verified'])};
|
| 274 |
-
f"
|
| 275 |
-
f"declared contract: {len(divergence.get('modified', []))} adapted, "
|
| 276 |
-
f"{len(divergence.get('removed', []))} removed, "
|
| 277 |
-
f"{len(divergence.get('added', []))} added",
|
| 278 |
flush=True,
|
| 279 |
)
|
| 280 |
|
|
@@ -308,108 +254,10 @@ def main() -> None:
|
|
| 308 |
|
| 309 |
# Supported upstream option: retain the live connection and observe every step.
|
| 310 |
# The existing disconnect recovery and process watchdog still reconnect on failure.
|
| 311 |
-
#
|
| 312 |
-
# A transient provider failure must not tear down the Minecraft connection.
|
| 313 |
-
# Upstream learn() lets an LLM error propagate: it leaves this process, the
|
| 314 |
-
# finally block below kills the Mineflayer child, and the watchdog restarts
|
| 315 |
-
# the runner -- so the bot visibly leaves and rejoins the server on every
|
| 316 |
-
# quota blip. Measured on Gemini_Bot: three leave/rejoin cycles in 3.5
|
| 317 |
-
# minutes, each one an openai.error.ServiceUnavailableError raised from
|
| 318 |
-
# curriculum.run_qa. Retry in place with bounded backoff instead.
|
| 319 |
-
# learn(reset_env=False) keeps the live connection, and because upstream
|
| 320 |
-
# sets self.resume = True after the first call, every retry takes the soft
|
| 321 |
-
# reset path and keeps the inventory.
|
| 322 |
-
max_attempts = int(env("VOYAGER_LEARN_MAX_ATTEMPTS", "0")) # 0 = retry until it works
|
| 323 |
-
delay = float(env("VOYAGER_LEARN_BACKOFF_SECONDS", "15"))
|
| 324 |
-
delay_cap = float(env("VOYAGER_LEARN_BACKOFF_CAP_SECONDS", "300"))
|
| 325 |
-
attempt = 0
|
| 326 |
-
|
| 327 |
-
# A watchdog for the failure mode that retry-forever hides: the process is
|
| 328 |
-
# alive, every error counter is zero, and nothing progresses because
|
| 329 |
-
# learn() is parked on a wedged dependency. Retrying cannot help there --
|
| 330 |
-
# the loop is blocked, not failing -- so the only recovery is to exit and
|
| 331 |
-
# let the supervisor start a fresh runner. Measured: the bot sat in that
|
| 332 |
-
# state for 45+ minutes with zero model calls. Progress is read from the
|
| 333 |
-
# checkpoint files (not from a log line), so it is immune to the log being
|
| 334 |
-
# absent, and the watchdog only arms once a task has actually completed,
|
| 335 |
-
# so a cold start is never killed.
|
| 336 |
-
stall_seconds = float(env("VOYAGER_STALL_SECONDS", "900"))
|
| 337 |
-
|
| 338 |
-
def _progress_fingerprint():
|
| 339 |
-
# Every file that changes when the loop advances. These are the same
|
| 340 |
-
# three voyager_learning._signature() watches, so the watchdog and the
|
| 341 |
-
# learning watcher agree on what "progress" means.
|
| 342 |
-
#
|
| 343 |
-
# Watching only completed_tasks.json made the watchdog blind to a bot
|
| 344 |
-
# that is working but failing: it would recycle a runner that was
|
| 345 |
-
# mid-attempt. A failure is progress -- the bot acted and the Critic
|
| 346 |
-
# judged -- so it resets the clock. Only a loop that does NOTHING for the
|
| 347 |
-
# whole window is a stall. A new skill counts too, because the skill
|
| 348 |
-
# library only grows when a task actually succeeded.
|
| 349 |
-
parts = []
|
| 350 |
-
for path in (
|
| 351 |
-
ckpt / "curriculum" / "completed_tasks.json",
|
| 352 |
-
ckpt / "curriculum" / "failed_tasks.json",
|
| 353 |
-
ckpt / "skill" / "skills.json",
|
| 354 |
-
):
|
| 355 |
-
try:
|
| 356 |
-
stat = path.stat()
|
| 357 |
-
parts.append((path.name, stat.st_mtime_ns, stat.st_size))
|
| 358 |
-
except OSError:
|
| 359 |
-
parts.append((path.name, None, None))
|
| 360 |
-
return tuple(parts)
|
| 361 |
-
|
| 362 |
-
def _watch_stall():
|
| 363 |
-
last_seen = _progress_fingerprint()
|
| 364 |
-
last_change = time.time()
|
| 365 |
-
while True:
|
| 366 |
-
time.sleep(60)
|
| 367 |
-
current = _progress_fingerprint()
|
| 368 |
-
if current != last_seen:
|
| 369 |
-
last_seen = current
|
| 370 |
-
last_change = time.time()
|
| 371 |
-
continue
|
| 372 |
-
idle = time.time() - last_change
|
| 373 |
-
if idle >= stall_seconds:
|
| 374 |
-
# os._exit is deliberate: the learning thread is blocked inside
|
| 375 |
-
# a call that will not return, so a cooperative shutdown would
|
| 376 |
-
# itself hang. Exiting hard lets the Space watchdog observe it.
|
| 377 |
-
print(
|
| 378 |
-
f"[voyager] STALL WATCHDOG: no completed task in {idle:.0f}s "
|
| 379 |
-
f"(limit {stall_seconds:.0f}s); exiting so the supervisor can "
|
| 380 |
-
f"restart the runner",
|
| 381 |
-
flush=True,
|
| 382 |
-
)
|
| 383 |
-
sys.stdout.flush()
|
| 384 |
-
sys.stderr.flush()
|
| 385 |
-
os._exit(75)
|
| 386 |
-
|
| 387 |
try:
|
| 388 |
quarantine_invalid_skills(voyager, ckpt)
|
| 389 |
quarantine_template_skills(voyager, ckpt)
|
| 390 |
-
|
| 391 |
-
threading.Thread(target=_watch_stall, daemon=True, name="voyager-stall-watch").start()
|
| 392 |
-
while True:
|
| 393 |
-
attempt += 1
|
| 394 |
-
try:
|
| 395 |
-
voyager.learn(reset_env=False)
|
| 396 |
-
break
|
| 397 |
-
except Exception as exc:
|
| 398 |
-
if max_attempts and attempt >= max_attempts:
|
| 399 |
-
print(
|
| 400 |
-
f"[voyager] learning loop failed {attempt} times; giving up: "
|
| 401 |
-
f"{type(exc).__name__}: {exc}",
|
| 402 |
-
flush=True,
|
| 403 |
-
)
|
| 404 |
-
raise
|
| 405 |
-
print(
|
| 406 |
-
f"[voyager] learning loop raised {type(exc).__name__}: {exc}; "
|
| 407 |
-
f"keeping the Minecraft connection and retrying in {delay:.0f}s "
|
| 408 |
-
f"(attempt {attempt})",
|
| 409 |
-
flush=True,
|
| 410 |
-
)
|
| 411 |
-
time.sleep(delay)
|
| 412 |
-
delay = min(delay * 2, delay_cap)
|
| 413 |
finally:
|
| 414 |
# Upstream's non-daemon log-monitor thread keeps Python alive after a
|
| 415 |
# fatal curriculum error. Stop only this runner's Mineflayer child so
|
|
@@ -425,8 +273,4 @@ def main() -> None:
|
|
| 425 |
|
| 426 |
|
| 427 |
if __name__ == "__main__":
|
| 428 |
-
|
| 429 |
-
main()
|
| 430 |
-
except BaseException as exc: # noqa: BLE001 - record it, then let the exit code stand
|
| 431 |
-
record_exit(os.environ.get("MC_USERNAME", "unknown"), exc)
|
| 432 |
-
raise
|
|
|
|
| 5 |
import logging
|
| 6 |
import shutil
|
| 7 |
import sys
|
|
|
|
|
|
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
VOYAGER_HOME = Path(os.environ.get("VOYAGER_HOME", "/tmp/voyager-runtime/Voyager"))
|
|
|
|
| 13 |
|
| 14 |
from voyager import Voyager # noqa: E402
|
| 15 |
from voyager_verification import (
|
| 16 |
+
quarantine_invalid_skills, quarantine_template_skills, task_spec,
|
|
|
|
| 17 |
)
|
| 18 |
from voyager_creative_critic import VerifiedCreativeCritic
|
| 19 |
+
from voyager_runtime_contract import attest_original_runtime, write_attestation
|
|
|
|
|
|
|
|
|
|
| 20 |
from voyager_metrics import attach_role_metrics
|
| 21 |
|
| 22 |
|
|
|
|
| 24 |
return os.environ.get(name, default)
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def main() -> None:
|
| 28 |
from voyager_learning import refresh_learning_state, start_learning_watcher
|
| 29 |
# Surface Mineflayer's subprocess logs as well as Python tracebacks.
|
| 30 |
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(levelname)s %(message)s")
|
| 31 |
+
model = env("VOYAGER_MODEL", "muse-spark-1.3-contributor-free")
|
| 32 |
+
aux_model = env("VOYAGER_AUX_MODEL", "muse-spark-1.3-contributor-free")
|
| 33 |
+
base_url = env("VOYAGER_OPENAI_BASE", "http://127.0.0.1:7864/v1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
api_key = env("VOYAGER_API_KEY", "local-voyager")
|
| 35 |
ckpt_dir = env("VOYAGER_CKPT_DIR", "/persist/minecraft-friends/voyager/ckpt-20260927-flat-y64-v1")
|
| 36 |
bot_username = env("MC_USERNAME", "Naldo_Bot")
|
| 37 |
brain_profile = env("VOYAGER_BRAIN_PROFILE", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
if bot_username == "Naldo_Bot":
|
| 39 |
+
try:
|
| 40 |
+
from naldo_opencode import (
|
| 41 |
+
ALLOWED_MODELS as NALDO_ALLOWED_MODELS,
|
| 42 |
+
BRAIN_PROFILE as NALDO_BRAIN_PROFILE,
|
| 43 |
+
BRIDGE_BASE as NALDO_BRIDGE_BASE,
|
| 44 |
+
is_allowed_model as _naldo_model_allowed,
|
| 45 |
+
)
|
| 46 |
+
except Exception as exc:
|
| 47 |
+
raise RuntimeError(f"Naldo OpenCode brain module unavailable: {exc}") from exc
|
| 48 |
+
if (
|
| 49 |
+
brain_profile != NALDO_BRAIN_PROFILE
|
| 50 |
+
or base_url != NALDO_BRIDGE_BASE
|
| 51 |
+
or not _naldo_model_allowed(model)
|
| 52 |
+
or not _naldo_model_allowed(aux_model)
|
| 53 |
+
):
|
| 54 |
+
raise RuntimeError(
|
| 55 |
+
f"Brain lock rejected {bot_username}: profile={brain_profile!r}, base={base_url!r}, "
|
| 56 |
+
f"model={model!r}, aux={aux_model!r}; Naldo requires profile={NALDO_BRAIN_PROFILE!r}, "
|
| 57 |
+
f"base={NALDO_BRIDGE_BASE!r}, models in {sorted(NALDO_ALLOWED_MODELS)} (opencode provider only)"
|
| 58 |
+
)
|
| 59 |
+
expected = None
|
| 60 |
elif bot_username == "Gemini_Bot":
|
| 61 |
+
expected = ("gemini-cli", "http://127.0.0.1:7863/v1", "gemini-3.5-flash-lite", "gemini-3.5-flash-lite")
|
|
|
|
|
|
|
|
|
|
| 62 |
else:
|
| 63 |
expected = None
|
| 64 |
+
if expected and (brain_profile, base_url, model, aux_model) != expected:
|
| 65 |
raise RuntimeError(
|
| 66 |
f"Brain lock rejected {bot_username}: profile={brain_profile!r}, base={base_url!r}, "
|
| 67 |
+
f"model={model!r}, aux={aux_model!r}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
)
|
| 69 |
ckpt = Path(ckpt_dir)
|
| 70 |
required_resume_defaults = {
|
|
|
|
| 207 |
critic_agent_temperature=0,
|
| 208 |
skill_manager_model_name=aux_model,
|
| 209 |
skill_manager_temperature=0,
|
| 210 |
+
skill_manager_retrieval_top_k=5,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
openai_api_request_timeout=int(env("OPENAI_API_REQUEST_TIMEOUT", "240")),
|
| 212 |
ckpt_dir=ckpt_dir,
|
| 213 |
resume=resume,
|
| 214 |
)
|
| 215 |
+
attestation = attest_original_runtime(voyager, VOYAGER_HOME)
|
|
|
|
|
|
|
|
|
|
| 216 |
attach_role_metrics(voyager, bot_username)
|
| 217 |
voyager.critic_agent = VerifiedCreativeCritic(voyager.critic_agent)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
write_attestation(env("VOYAGER_ATTESTATION_FILE", ""), attestation, bot_username)
|
| 219 |
print(
|
| 220 |
f"[voyager] verified original engine {attestation['upstream_commit']} for "
|
| 221 |
f"{bot_username}; decision methods attested: "
|
| 222 |
+
f"{', '.join(attestation['decision_methods_verified'])}; official Critic with "
|
| 223 |
+
f"independent world evidence",
|
|
|
|
|
|
|
|
|
|
| 224 |
flush=True,
|
| 225 |
)
|
| 226 |
|
|
|
|
| 254 |
|
| 255 |
# Supported upstream option: retain the live connection and observe every step.
|
| 256 |
# The existing disconnect recovery and process watchdog still reconnect on failure.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
try:
|
| 258 |
quarantine_invalid_skills(voyager, ckpt)
|
| 259 |
quarantine_template_skills(voyager, ckpt)
|
| 260 |
+
voyager.learn(reset_env=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
finally:
|
| 262 |
# Upstream's non-daemon log-monitor thread keeps Python alive after a
|
| 263 |
# fatal curriculum error. Stop only this runner's Mineflayer child so
|
|
|
|
| 273 |
|
| 274 |
|
| 275 |
if __name__ == "__main__":
|
| 276 |
+
main()
|
|
|
|
|
|
|
|
|
|
|
|
voyager_stack.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|