docs: add planner-agent docs and session summary 2026-05-27

- services/planner-agent/README.md: full service doc (what it does,
  LLM fallback chain, env vars, deploy steps, local run, redis-cli
  end-to-end test, healthcheck)
- README.md: add Agent System section with all agents and their roles
- docs/sessions/2026-05-27-planner-agent.md: session summary (built
  files, architectural decisions, problems + solutions, deployment
  status, pending work)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Oskar Kapala 2026-05-27 22:35:59 +02:00
parent ff6fda1f04
commit 5ccdfa0ca6
3 changed files with 485 additions and 0 deletions

View file

@ -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).

View file

@ -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)
```

View file

@ -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/<action_id>.json
events/<date>/<node>/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/<OLLAMA_MODEL> 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": "<nazwa serwisu>",
"node": "<nazwa noda>",
"reason": "<wyjaśnienie, min. 10 znaków>",
"confidence": <0.01.0>,
"requires_human": <true|false>
}
```
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
```