# SESSION: Budowa planner-agent — LLM-based diagnostics **DATA:** 2026-05-27 **REZULTAT:** planner-agent działa na SOLARIA (`healthy`), Ollama primary, cloud fallback gotowy do włączenia --- ## Co zostało zbudowane ### `services/planner-agent/src/llm_router.py` Moduł LLM routing z local-first fallback chain: - **`LLMRouter`** — główna klasa routingu przez litellm - **`ModelConfig`** — konfiguracja jednego modelu (name, timeout, api_base, extra_kwargs) - **`ModelMetrics`** — liczniki per model × outcome (`success`/`fallback`/`error`); success_rate - **`RouteResult`** — wynik routingu z `content`, `model_used`, `attempts`, `latency_ms` - **`AttemptRecord`** — zapis jednej próby (model, outcome, reason, latency_ms) - **`_extract_json_from_fence()`** — wydobywa JSON z bloków ` ```json ``` ` jeśli model nie odpowie czystym JSON Domyślny chain: `ollama/qwen2.5:7b` (8s) → `claude-haiku-4-5-20251001` (30s) → `claude-sonnet-4-6` (30s) Metryki każdego wywołania publikowane na Redis kanał `llm_router_metrics`. ### `services/planner-agent/src/planner.py` Główna pętla agenta: - **`PlannerAgent`** — async agent: Redis sub → diagnoza LLM → pending action file → event - **`HealthEvent`** — znormalizowane zdarzenie zdrowotne z Redis (node, service, event_type, severity, payload) - **`ActionProposal`** — propozycja akcji z pełnymi metadanymi; `.to_action_file()` → format executora - **`CooldownTracker`** — gate 5-minutowy per `svc_key` (node/service); NIE rejestruje jeśli LLM się wysypał - **`parse_event()`** — normalizuje dwa formaty wejściowe (node-agent / control-plane) - **`write_pending_action()`** — atomiczny zapis: `.tmp` → rename - **`emit_event()`** — zapis zdarzenia `remediation_started` do systemu plików (bez importów z control-plane) Pipeline: ``` Redis msg → parse_event() → benign skip → cooldown gate → _propose_action() (LLM) → write_pending_action() → emit_event("remediation_started") ``` ### Pliki towarzyszące | Plik | Opis | |------|------| | `service.yaml` | Kontrakt operacyjny: owner_node=solaria, deps=redis+ollama, healthcheck=file | | `docker-compose.yml` | env_file + extra_hosts:host-gateway + ANTHROPIC_API_KEY w environment | | `Dockerfile` | python:3.11-slim, litellm, redis, jsonschema, structlog | | `healthcheck.sh` | Sprawdza wiek pliku heartbeat (max 300s) | | `requirements.txt` | litellm, redis, jsonschema, structlog | | `tests/test_planner.py` | 49 testów jednostkowych | | `tests/test_llm_router.py` | 34 testy jednostkowe | --- ## Kluczowe decyzje architektoniczne ### 1. HITL invariant (Human-in-the-loop) Planner **wyłącznie** zapisuje do `actions/pending/`. Executor wymaga pliku w `actions/approved/`. Planner nigdy nie wykona akcji samodzielnie — to fundamentalna zasada systemu. Implementacja: `write_pending_action()` pisze do `pending/`, żadna ścieżka w kodzie nie dotyka `approved/`. ### 2. Cooldown gate Per `svc_key` (= `node/service`), domyślnie 5 minut. Cel: nie zalewać operatora powtórzonymi propozycjami dla tego samego serwisu. **Kluczowa decyzja:** cooldown NIE jest rejestrowany jeśli cały chain LLM się wysypał. Dzięki temu kolejne zdarzenie może spróbować ponownie, zamiast być cicho zablokowanym przez 5 minut mimo że nie powstała żadna propozycja. ### 3. Fallback chain — local-first Kolejność: Ollama (lokalny GPU) → Haiku → Sonnet. Uzasadnienie: - Ollama nie wysyła danych do zewnętrznych serwisów; niskie opóźnienie dla prostych przypadków - Haiku = szybki i tani cloud fallback - Sonnet = ostatnia deska ratunku dla trudnych przypadków Odrzucenie modelu na podstawie: timeout, błąd sieci, wzorzec odmowy, invalid JSON, schema error. ### 4. Brak importów z control-plane `services/planner-agent/` jest w pełni samodzielny. Nie importuje nic z `services/control-plane/`. Emisja eventów jest implementowana lokalnie (kopia logiki `scripts/lib/events.py`). Uzasadnienie: planner musi działać nawet jeśli control-plane jest offline; oddzielne cykl deploymentu. ### 5. structlog z PrintLoggerFactory Nie używamy `structlog.stdlib.add_logger_name` — `PrintLogger` nie ma atrybutu `.name`. Zamiast tego łańcuch procesorów: `add_log_level` → `TimeStamper` → `StackInfoRenderer` → `format_exc_info` → `JSONRenderer`. ### 6. NODE_NAME czytany w czasie wywołania, nie importu `_emit_event_sync` czyta `NODE_NAME` z modułowego `NODE_NAME` przy każdym wywołaniu (nie jako default parameter). Umożliwia patchowanie w testach. --- ## Problemy napotkane i rozwiązania ### Problem: `localhost` w kontenerze nie sięga do hosta **Kontekst:** Ollama działa na SOLARIA pod `localhost:11434`. Kontener Docker z domyślną siecią bridge nie może sięgnąć do hosta przez `localhost`. **Rozwiązanie:** 1. Dodano `extra_hosts: - "host-gateway:host-gateway"` do docker-compose.yml 2. `.env` używa `OLLAMA_HOST=http://host-gateway:11434` ### Problem: `environment` vs `env_file` — podwójne zmienne **Kontekst:** Pierwsza wersja docker-compose.yml miała wszystkie zmienne hardkodowane w sekcji `environment` z fallback wartościami (`${VAR:-default}`). Powodowało to że `.env` był opcjonalny a nie wymagany. **Rozwiązanie:** Usunięto wszystkie zmienne runtime z `environment`, przeniesiono do `env_file`. Pozostał tylko `ANTHROPIC_API_KEY` w `environment` (opcjonalny sekret, nie powinien być w pliku na dysku). ### Problem: `structlog.stdlib.add_logger_name` crashuje z PrintLogger **Symptom:** `AttributeError: 'PrintLogger' object has no attribute 'name'` **Rozwiązanie:** Usunięto `add_logger_name` z łańcucha procesorów. Nie jest kompatybilny z `PrintLoggerFactory`. ### Problem: verify stage failuje zaraz po starcie **Symptom:** `deploy.sh` raportuje FAILED przy verify bo heartbeat nie istnieje. **Przyczyna:** Race condition — agent potrzebuje kilku sekund na uruchomienie pętli i pierwsze `touch()` heartbeatu. **Rozwiązanie:** Nie jest to prawdziwy błąd. Docker healthcheck ma `start_period: 30s`. Kontener pokazuje `(healthy)` po 30s od startu. ### Problem: git pull z divergent branches na solaria **Symptom:** Solaria miała 2 lokalne commity nie będące na Forgejo + ręczne zmiany w working tree. `git pull` failował z "Need to specify how to reconcile divergent branches." **Rozwiązanie:** ```bash git checkout -- services/planner-agent/docker-compose.yml # porzuć ręczne zmiany git fetch origin git rebase origin/master # rebase local commits on top of master ``` --- ## Status deploymentu na SOLARIA ``` Container: planner-agent Up ~30m (healthy) Image: planner-agent-planner-agent Node: solaria (100.100.231.104) Heartbeat: /opt/homelab/state/planner-agent.heartbeat (age 0s) Channels subscribed: - health_events - world_updates LLM chain: PRIMARY: ollama/qwen2.5-coder:14b @ http://host-gateway:11434 FALLBACK: claude-haiku-4-5-20251001 (disabled — brak ANTHROPIC_API_KEY) FALLBACK: claude-sonnet-4-6 (disabled — brak ANTHROPIC_API_KEY) Redis: redis://100.108.208.3:6379 ✓ connected ``` --- ## Co zostało na później ### 1. ANTHROPIC_API_KEY — cloud fallback wyłączony Haiku i Sonnet są skonfigurowane w chain ale nie mają klucza API. Gdy Ollama nie da rady (złożony przypadek / timeout), chain się wysypie bez fallbacku. Aby włączyć: ```bash ssh oskar@100.100.231.104 echo "ANTHROPIC_API_KEY=sk-ant-..." >> /opt/homelab/config/planner-agent/.env docker compose -f ~/homelab-codex-ws/services/planner-agent/docker-compose.yml up -d ``` ### 2. End-to-end test z prawdziwym eventem Planner jest podłączony do Redis i nasłuchuje, ale żadne zdarzenie jeszcze nie przeszło przez pełną ścieżkę (LLM call → pending action → operator UI). Test: ```bash redis-cli -h 100.108.208.3 PUBLISH health_events '{ "type": "service_unhealthy", "node": "piha", "service": "mosquitto", "severity": "error", "payload": {"reason": "container exited"}, "timestamp": "2026-05-27T20:00:00Z" }' # Obserwuj: docker logs planner-agent -f # Sprawdź: ls /opt/homelab/actions/pending/ ``` ### 3. Solaria local commits Solaria ma 2 lokalne commity (`feat: add ECC skills`, `fix: remove duplicate CLAUDE.md sections`) które nie są na Forgejo. Zostały zrebase'owane na top of master ale nie wypchnięte. Należy je wypchnąć lub zreviewować i ewentualnie squashować. ### 4. Integracja z operator UI / Telegram Propozycje w `actions/pending/` nie mają jeszcze kanału notyfikacji do operatora. Telegram bot powinien wysyłać powiadomienie gdy pojawi się nowy plik w `pending/`. --- ## Commity tej sesji ``` ff6fda1 planner-agent: use env_file, keep only ANTHROPIC_API_KEY in environment ca37fca Add planner-agent: LLM-powered remediation planner (llm_router.py, planner.py, tests, service.yaml, docker-compose.yml, healthcheck.sh, Dockerfile) ```