diff --git a/README.md b/README.md index 8007670..c8dab50 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,22 @@ The homelab consists of several nodes connected via a Tailscale internal mesh. | **PIHA** | Infra Node | Core infrastructure services, automation, and monitoring. | | **VPS** | Edge Node | Public ingress, reverse proxy, and edge services. | +## Agent System + +The homelab uses a multi-agent orchestration model with human-in-the-loop for destructive actions: + +| Agent | Node | Role | +|-------|------|------| +| **stability-agent** | all nodes | Per-node watchdog — monitors Docker, disk, Tailscale, MQTT; emits events | +| **node-agent** | all nodes | Publishes container health events to Redis pub/sub | +| **observer** | VPS | Synthesizes world state from events into `/opt/homelab/world/*.json` | +| **supervisor** | VPS | Detects drift between desired and actual state; writes `pending` actions | +| **planner-agent** | SOLARIA | LLM-powered diagnosis — listens to Redis, proposes remediation actions | +| **executor** | VPS | Executes actions only after operator approval | +| **operator-ui** + **telegram-bot** | VPS / PIHA | Operator reviews and approves/rejects pending actions | + +Action approval flow: `pending/` → operator approves → `approved/` → executor runs. + ## Repository Structure - `docs/`: [Infrastructure Standards](docs/standards.md) and [Deployment Conventions](docs/deployment.md). diff --git a/docs/sessions/2026-05-27-planner-agent.md b/docs/sessions/2026-05-27-planner-agent.md new file mode 100644 index 0000000..93816ec --- /dev/null +++ b/docs/sessions/2026-05-27-planner-agent.md @@ -0,0 +1,234 @@ +# 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) +``` diff --git a/services/planner-agent/README.md b/services/planner-agent/README.md new file mode 100644 index 0000000..ac7f19d --- /dev/null +++ b/services/planner-agent/README.md @@ -0,0 +1,235 @@ +# planner-agent + +Asynchroniczny agent diagnozujący zdarzenia zdrowotne w homelabowej infrastrukturze. +Nasłuchuje na kanałach Redis pub/sub, wysyła zdarzenie do LLM i zapisuje propozycję +akcji do `actions/pending/` — gdzie musi ją zaakceptować operator. + +--- + +## Co robi + +``` +Redis pub/sub LLM (Ollama → Haiku → Sonnet) + health_events ──────────────► diagnoza zdarzenia + world_updates ──────────────► propozycja JSON + │ + cooldown gate (5 min / svc_key) + │ + actions/pending/.json + │ + events///evt-*.json + (typ: remediation_started) +``` + +**HITL invariant:** planner pisze wyłącznie do `actions/pending/`. +Executor wymaga pliku w `actions/approved/` — planner nigdy tego katalogu nie dotyka. + +### Obsługiwane kanały + +| Kanał | Źródło | Opis | +|-------|--------|------| +| `health_events` | node-agent, stability-agent | Zdarzenia zdrowotne kontenerów i systemu | +| `world_updates` | observer (control-plane) | Zmiany w world state | + +### Benign events (pomijane bez wywołania LLM) + +`service_healthy`, `service_recovered`, `node_online`, `deployment_completed`, +`deployment_started`, `remediation_started`, `remediation_completed` + +--- + +## Fallback chain LLM + +``` +1. ollama/ timeout 8 s (lokalny GPU — SOLARIA) +2. claude-haiku-4-5-20251001 timeout 30 s (Anthropic cloud) +3. claude-sonnet-4-6 timeout 30 s (Anthropic cloud) +``` + +Model jest odrzucany, gdy: +- przekroczy timeout +- zwróci błąd sieci / API +- odpowie tekstem pasującym do wzorca odmowy (`"I cannot"`, `"nie wiem"`, …) +- zwróci JSON niezgodny ze schematem propozycji + +Metryki każdego wywołania są publikowane na kanał Redis `llm_router_metrics`. + +### Schemat propozycji (JSON Schema) + +```json +{ + "action": "restart | redeploy | notify | ignore", + "service": "", + "node": "", + "reason": "", + "confidence": <0.0–1.0>, + "requires_human": +} +``` + +Mapowanie na typ executora: + +| LLM action | Executor type | Risk level | +|------------|--------------------|------------| +| restart | container_restart | low | +| redeploy | redeploy | guarded | +| notify | notify | low | +| ignore | *(nie zapisuje)* | — | + +--- + +## Zmienne środowiskowe + +Wszystkie zmienne runtime żyją w `/opt/homelab/config/planner-agent/.env` na węźle. +**Nie commituj tego pliku** — nie jest w repo. + +```dotenv +# Ollama — lokalny GPU węzła (np. SOLARIA) +# Użyj host-gateway zamiast localhost (kontener nie może sięgnąć hosta przez localhost) +OLLAMA_HOST=http://host-gateway:11434 +OLLAMA_MODEL=qwen2.5-coder:14b # dowolny model dostępny w ollama list + +# Redis na piha +REDIS_URL=redis://100.108.208.3:6379 + +# Tożsamość noda +NODE_NAME=solaria + +# Cooldown między propozycjami dla tego samego serwisu +COOLDOWN_SECONDS=300 + +# Ścieżka do runtime state +RUNTIME_PATH=/opt/homelab + +# Opcjonalnie — wymagane do cloud fallback (haiku/sonnet) +# ANTHROPIC_API_KEY=sk-ant-... +``` + +`ANTHROPIC_API_KEY` jest jedyną zmienną przekazywaną przez sekcję `environment` +w docker-compose.yml (nie jest w env_file — sekret injektowany przez operatora). + +--- + +## Deployment na SOLARIA + +```bash +# 1. Przygotuj .env na solaria +ssh oskar@100.100.231.104 +mkdir -p /opt/homelab/config/planner-agent +cat > /opt/homelab/config/planner-agent/.env << 'EOF' +OLLAMA_HOST=http://host-gateway:11434 +OLLAMA_MODEL=qwen2.5-coder:14b +REDIS_URL=redis://100.108.208.3:6379 +NODE_NAME=solaria +COOLDOWN_SECONDS=300 +RUNTIME_PATH=/opt/homelab +EOF + +# 2. Deploy przez standardowy skrypt (z SATURN) +scripts/deploy/deploy.sh --service planner-agent + +# 3. Weryfikacja +docker ps --filter name=planner-agent +docker logs planner-agent --tail 30 +ls -la /opt/homelab/state/planner-agent.heartbeat +``` + +> **Uwaga:** stage `verify` w deploy.sh może failować bezpośrednio po starcie +> (race condition — heartbeat jest pisany co ~5 s). Kontener jest zdrowy gdy +> `docker ps` pokazuje `(healthy)`. + +--- + +## Uruchamianie lokalne (bez Dockera) + +```bash +cd services/planner-agent + +# Zainstaluj zależności +pip install -r requirements.txt + +# Wyeksportuj zmienne (lub stwórz .env i użyj `export $(cat .env | xargs)`) +export REDIS_URL=redis://localhost:6379 +export OLLAMA_HOST=http://localhost:11434 +export OLLAMA_MODEL=qwen2.5:7b +export NODE_NAME=dev-local +export RUNTIME_PATH=/tmp/homelab-dev +export COOLDOWN_SECONDS=10 + +# Uruchom +python src/planner.py +``` + +--- + +## Testowanie + +### Testy jednostkowe + +```bash +cd services/planner-agent +pip install -r requirements.txt pytest pytest-asyncio +pytest tests/ -v +# 49 testów planner + 34 testy llm_router +``` + +### Ręczny end-to-end test przez Redis + +```bash +# Opublikuj sztuczne zdarzenie unhealthy na kanale health_events +redis-cli -h 100.108.208.3 PUBLISH health_events '{ + "type": "service_unhealthy", + "node": "piha", + "service": "mosquitto", + "severity": "error", + "payload": {"exit_code": 1, "reason": "OOMKilled"}, + "timestamp": "2026-05-27T20:00:00Z" +}' + +# Obserwuj logi planera +docker logs planner-agent -f + +# Sprawdź czy propozycja trafiła do pending +ls /opt/homelab/actions/pending/ +cat /opt/homelab/actions/pending/plan-piha-mosquitto-*.json +``` + +### Śledzenie metryk LLM + +```bash +# Subskrybuj metryki routera w czasie rzeczywistym +redis-cli -h 100.108.208.3 SUBSCRIBE llm_router_metrics +``` + +--- + +## Healthcheck + +Skrypt `healthcheck.sh` sprawdza czy plik heartbeat +`/opt/homelab/state/planner-agent.heartbeat` jest świeższy niż 300 s. +Heartbeat jest pisany na górze każdej iteracji pętli (≤5 s interwał). + +```bash +# Ręczny test healthchecku +docker exec planner-agent /bin/sh /app/healthcheck.sh +``` + +--- + +## Struktura plików + +``` +services/planner-agent/ +├── src/ +│ ├── planner.py # Główna pętla agenta +│ └── llm_router.py # Routing LLM z fallback chain +├── tests/ +│ ├── test_planner.py # 49 testów +│ └── test_llm_router.py # 34 testy +├── docker-compose.yml +├── Dockerfile +├── requirements.txt +├── service.yaml # Kontrakt operacyjny dla agentów +├── healthcheck.sh +└── README.md +```