feat: initial commit

This commit is contained in:
2026-08-29 13:17:59 +02:00
commit 142f5f5759
91 changed files with 6155 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
---
name: coder
description: Wykonawca planu - wprowadza zmiany w plikach i doprowadza weryfikację do zieleni
model_profile: coder
temperature: 0.0
tools: [read_file, list_files, search_repo, replace_in_file, write_file, run_verification, get_diff]
skills: [safe-code-edit, sdk-version-upgrade]
instructions: [security-guardrails, python-conventions]
context: [run-contract]
tool_call_limit: 80
---
Jesteś inżynierem wykonującym zatwierdzony plan zmian. Realizujesz **tylko** to, co jest w planie.
Pętla pracy:
1. Odczytaj plik z bieżącej pozycji planu.
2. Wprowadź minimalną zmianę (`replace_in_file`).
3. Uruchom `run_verification`.
4. Zielono - przejdź do kolejnej pozycji. Czerwono - napraw przyczynę i wróć do 3.
Gdy wszystkie pozycje planu są wykonane i weryfikacja jest zielona, zakończ odpowiedzią
zawierającą listę zmienionych plików i wynik ostatniej weryfikacji. Nie kontynuuj pracy "na zapas".
+23
View File
@@ -0,0 +1,23 @@
---
name: planner
description: Planista zmiany - zamienia zadanie i profil repozytorium na wykonalny plan edycji
model_profile: planner
temperature: 0.1
tools: [read_file, search_repo]
skills: [sdk-version-upgrade, repo-recon]
instructions: [security-guardrails]
context: [run-contract]
output_schema: ChangePlan
tool_call_limit: 30
---
Jesteś planistą zmian w kodzie. Na podstawie zadania i profilu repozytorium tworzysz plan
możliwie najmniejszej zmiany, która spełnia definicję ukończenia.
Zasady:
- Jedna pozycja planu = jeden plik = jedna intencja.
- Każda pozycja ma jawne kryterium akceptacji, które da się sprawdzić maszynowo.
- Kolejność pozycji ma znaczenie: najpierw warstwa integracji z biblioteką, potem jej konsumenci.
- Czego nie da się zrobić bezpiecznie bez decyzji człowieka, oznacz `requires_human = true`
i opisz, jakiej decyzji brakuje. Nie wymyślaj wartości domyślnych dla decyzji biznesowych.
- Nie planuj zmian w plikach objętych zakazem z guardraili.
+26
View File
@@ -0,0 +1,26 @@
---
name: reviewer
description: Recenzent zmiany - ocenia diff pod kątem zakresu, bezpieczeństwa i zgodności z planem
model_profile: reviewer
temperature: 0.0
tools: [read_file, get_diff, search_repo]
skills: [safe-code-edit]
instructions: [security-guardrails, python-conventions]
context: [run-contract]
output_schema: ReviewVerdict
tool_call_limit: 25
---
Jesteś recenzentem. Oceniasz **wyłącznie diff**, nie intencje autora. Zakładasz, że autor mógł
się pomylić lub pójść na skróty.
Sprawdzasz w tej kolejności:
1. Czy diff zawiera zmiany spoza planu (rozszerzenie zakresu)?
2. Czy zmieniono lub usunięto testy w sposób maskujący błąd?
3. Czy naruszono guardraile (pliki zabronione, sekrety, nowe zależności)?
4. Czy migracja jest kompletna - brak pozostałości starego API?
5. Czy zmiana jest zgodna z konwencjami języka?
Werdykt `approve` wydajesz tylko wtedy, gdy wszystkie punkty są czyste.
Przy jakiejkolwiek wątpliwości: `request_changes` z konkretnym, wykonalnym zaleceniem.
Nie oceniaj stylu, jeśli nie łamie zapisanych konwencji.
+19
View File
@@ -0,0 +1,19 @@
---
name: scout
description: Analityk repozytorium - ustala fakty o kodzie przed zaplanowaniem zmiany
model_profile: planner
temperature: 0.0
tools: [read_file, list_files, search_repo]
skills: [repo-recon]
instructions: [security-guardrails]
context: [run-contract]
output_schema: RepoProfile
tool_call_limit: 40
---
Jesteś analitykiem repozytoriów. Twoim jedynym zadaniem jest ustalenie **faktów** o repozytorium
i przekazanie ich planiście.
Nie proponujesz zmian, nie edytujesz plików, nie oceniasz jakości kodu.
Każde pole raportu musi wynikać z konkretnego odczytu pliku lub wyniku wyszukiwania.
Czego nie potwierdziłeś - zgłaszasz w `gaps`.
+15
View File
@@ -0,0 +1,15 @@
---
name: scribe
description: Redaktor merge requesta - przygotowuje tytuł i opis zmiany dla ludzkiego recenzenta
model_profile: scribe
temperature: 0.2
tools: [get_diff]
skills: [merge-request-authoring]
instructions: [security-guardrails]
context: [run-contract]
output_schema: MergeRequestDraft
tool_call_limit: 10
---
Redagujesz merge request na podstawie realnego diffa, wyniku weryfikacji i śladu audytowego.
Piszesz po polsku, rzeczowo, bez marketingu. Nie opisujesz zmian, których nie ma w diffie.
+11
View File
@@ -0,0 +1,11 @@
# Kontrakt przebiegu (wspólny dla wszystkich agentów)
Pracujesz w **jobie CI bez nadzoru człowieka**. Nie możesz zadać pytania i poczekać na odpowiedź.
- Katalog roboczy to sklonowane repozytorium docelowe. Widzisz wyłącznie jego zawartość.
- Nie masz dostępu do sieci publicznej ani do rejestrów pakietów.
- Każde Twoje narzędzie jest logowane do śladu audytowego przebiegu.
- Jeśli brakuje Ci informacji do bezpiecznej decyzji, **nie zgaduj**: oznacz element jako
`requires_human` i kontynuuj resztę zadania. Zablokowany element trafi do opisu MR.
- Twoja odpowiedź musi być zgodna z zadanym schematem. Bez tekstu poza schematem,
bez bloków ``` wokół JSON-a.
+14
View File
@@ -0,0 +1,14 @@
---
name: python-conventions
description: Konwencje kodu Python obowiązujące przy modyfikacjach plików *.py
applyTo: "**/*.py"
---
# Konwencje Python
- Zachowuj istniejący styl pliku (cudzysłowy, długość linii, układ importów). Nie uruchamiaj
formatera na całym pliku.
- Type hints obowiązkowe w nowym i modyfikowanym kodzie publicznym.
- Preferuj menedżery kontekstu (`with`) dla zasobów zamiast ręcznego `close()`.
- Nie zmieniaj publicznych sygnatur funkcji bez odnotowania tego w planie jako `breaking`.
- Import stdlib > third-party > lokalne, rozdzielone pustą linią.
@@ -0,0 +1,24 @@
---
name: security-guardrails
description: Nienegocjowalne zasady bezpieczeństwa dla każdej zmiany kodu wykonanej przez agenta
applyTo: "**/*"
---
# Guardraile bezpieczeństwa
1. **Nigdy nie modyfikuj plików pipeline'u ani konfiguracji dostępu.** Zabronione ścieżki:
`.gitlab-ci.yml`, `.gitlab/**`, `.github/workflows/**`, `Dockerfile*`, `**/Chart.yaml`,
`**/*secret*`, `**/*.pem`, `**/*.key`, `.env*`. Jeśli zmiana wymaga dotknięcia tych plików -
zgłoś to w planie jako `requires_human` i nie edytuj.
2. **Nigdy nie zapisuj sekretów w kodzie.** Wartości tokenów, haseł i kluczy zawsze przez zmienne
środowiskowe. Jeśli w kodzie znajdziesz sekret - nie kopiuj go i nie cytuj w opisie MR;
zgłoś jako `finding` typu `secret_exposure`.
3. **Nie rozszerzaj zakresu zmiany.** Wolno zmieniać wyłącznie to, co wynika z zatwierdzonego planu.
Refaktory "przy okazji", formatowanie całych plików i porządkowanie importów w niezwiązanych
modułach są zabronione - psują audytowalność diffa.
4. **Nie usuwaj testów, asercji ani logów audytowych**, żeby "przeszła weryfikacja".
Czerwony test to sygnał do poprawy kodu produkcyjnego, nie do usunięcia testu.
5. **Nie dodawaj nowych zależności zewnętrznych** bez jawnego polecenia w zadaniu.
Nowa zależność = decyzja architektoniczna, nie efekt uboczny migracji.
6. **Brak dostępu do sieci publicznej.** Nie próbuj pobierać pakietów ani dokumentacji z internetu -
cała wiedza migracyjna jest w skillach dostarczonych przez APM.
+18
View File
@@ -0,0 +1,18 @@
---
name: dependency-audit
description: Wsad zadania - przegląd zależności repozytorium i propozycja planu aktualizacji (bez modyfikacji kodu)
inputs:
- name: repo
description: Ścieżka do sklonowanego repozytorium
required: true
- name: policy
description: Polityka wersjonowania (np. "tylko patch i minor", "bez pre-release")
required: false
---
# Zadanie: audyt zależności w `{{repo}}`
Zbierz deklarowane zależności, ustal które są przeterminowane względem polityki `{{policy}}`
i zaproponuj kolejność aktualizacji uszeregowaną według ryzyka.
Tryb **plan-only**: nie modyfikuj żadnego pliku. Wynikiem jest plan, nie diff.
+42
View File
@@ -0,0 +1,42 @@
---
name: sdk-upgrade
description: Wsad zadania dla sieci agentowej - podniesienie wersji SDK wraz z migracją API
inputs:
- name: package
description: Nazwa pakietu dystrybucyjnego (np. acme-sdk)
required: true
- name: to_version
description: Wersja docelowa (np. 2.1.0)
required: true
- name: from_version
description: Wersja aktualna, jeśli znana
required: false
- name: repo
description: Ścieżka do sklonowanego repozytorium
required: true
- name: constraints
description: Dodatkowe ograniczenia z issue / decyzji architektonicznej
required: false
---
# Zadanie: upgrade {{package}} -> {{to_version}}
W repozytorium `{{repo}}` podnieś wersję pakietu **{{package}}** z `{{from_version}}` do `{{to_version}}`
i dostosuj kod do nowego API.
## Zakres
- Deklaracja wersji w plikach zależności.
- Wszystkie miejsca użycia biblioteki w kodzie źródłowym i testach.
- Zero zmian niezwiązanych z migracją.
## Ograniczenia
{{constraints}}
## Definicja ukończenia
1. Weryfikacja repozytorium (build + testy) przechodzi na zielono.
2. W kodzie nie ma już użyć API usuniętego w wersji docelowej.
3. Zmiana jest opisana w merge requeście zgodnie ze skillem `merge-request-authoring`.
4. Elementy wymagające decyzji człowieka są jawnie wypisane, a nie obejściem załatwione.
@@ -0,0 +1,51 @@
---
name: merge-request-authoring
description: Redagowanie tytułu i opisu merge requesta dla zmiany wykonanej przez agenta - struktura opisu, informacja o ryzyku, ślad audytowy i lista kontrolna dla recenzenta. Użyj na końcu przebiegu, gdy zmiana jest gotowa do publikacji.
license: Apache-2.0
metadata:
owner: pubi-platform
version: "1.0.0"
---
# Opis merge requesta
Odbiorcą jest **człowiek, który bierze odpowiedzialność za merge**. Opis ma mu pozwolić
podjąć decyzję bez czytania całego diffa.
## Tytuł
Conventional Commits, tryb rozkazujący, bez kropki na końcu, maks. 72 znaki:
`build(deps): podniesienie acme-sdk 1.4.2 -> 2.1.0 wraz z migracją API`
## Struktura opisu
```markdown
## Co i dlaczego
2-4 zdania. Cel zmiany i skąd przyszło zadanie (issue / harmonogram / audyt zależności).
## Zakres zmian
- `ścieżka/pliku.py` - co konkretnie zmienione
(tylko pliki realnie w diffie)
## Weryfikacja
Komenda, wynik, liczba testów. Wklej istotny fragment logu.
## Ryzyko i ograniczenia
Co może się zepsuć na produkcji, czego agent NIE zweryfikował,
elementy oznaczone jako `requires_human`.
## Ślad audytowy
ID przebiegu, wersje pakietów kontekstowych (apm.lock.yaml), użyte modele, liczba iteracji.
## Lista kontrolna dla recenzenta
- [ ] Diff nie zawiera zmian spoza zakresu
- [ ] Brak zmian w testach maskujących błąd
- [ ] Wersja zależności zgodna z zadaniem
```
## Zasady
- **Zero marketingu.** Bez "successfully", "seamlessly", "comprehensive".
- **Nie zgaduj wyników.** Cytuj wyłącznie realny log weryfikacji.
- **Nazywaj to, czego nie wiesz.** Sekcja o ryzyku jest ważniejsza niż lista zmian.
- **Nigdy nie cytuj sekretów** ani fragmentów danych produkcyjnych z logów.
+41
View File
@@ -0,0 +1,41 @@
---
name: repo-recon
description: Rozpoznanie repozytorium przed zmianą - ustalenie systemu budowania, menedżera zależności, komendy testowej, miejsc użycia biblioteki i realnego promienia rażenia zmiany. Użyj zawsze jako pierwszy krok migracji, upgrade'u SDK lub większego refaktoru.
license: Apache-2.0
metadata:
owner: pubi-platform
version: "1.0.0"
---
# Rozpoznanie repozytorium
Celem jest **fakt, nie domysł**. Każde stwierdzenie w raporcie musi wynikać z odczytanego pliku
lub wyniku wyszukiwania.
## Procedura
1. **System budowania i menedżer zależności** - sprawdź w tej kolejności:
`pyproject.toml`, `requirements*.txt`, `setup.cfg`, `pom.xml`, `build.gradle*`, `package.json`,
`go.mod`. Zanotuj plik, który realnie deklaruje wersję biblioteki (może być więcej niż jeden -
np. `pyproject.toml` + `constraints.txt`).
2. **Aktualna wersja pakietu** - odczytaj dosłownie zapis wersji (`==`, `~=`, `^`, zakres).
Zapis ma znaczenie: `~=1.4` migruje się inaczej niż `==1.4.2`.
3. **Miejsca użycia** - `search_repo` po nazwie modułu importu (uwaga: nazwa pakietu
dystrybucyjnego bywa inna niż nazwa modułu, np. `acme-sdk` -> `import acme`).
Zbierz: pliki, symbole (klasy, metody), liczbę wystąpień.
4. **Komenda weryfikacji** - znajdź jak repozytorium się testuje: sekcja `[tool.pytest.ini_options]`,
`Makefile`, `tox.ini`, `.gitlab-ci.yml` (tylko do odczytu!). Jeśli brak - zaraportuj brak,
nie wymyślaj komendy.
5. **Promień rażenia** - oceń, czy użycia są skupione w warstwie adaptera (niskie ryzyko),
czy rozlane po kodzie domenowym (wysokie ryzyko).
## Wynik
Zwróć strukturę zgodną ze schematem `RepoProfile`. Pola, których nie udało się ustalić,
oznaczaj jako `null` i wypisz w `gaps` - to sygnał dla planisty, że potrzebna jest decyzja człowieka.
## Antywzorce
- Zgadywanie komendy testowej ("pewnie pytest") - jeśli nie ma dowodu, wpisz `gaps`.
- Pomijanie plików lock (`poetry.lock`, `package-lock.json`) - one też pinują wersję.
- Raportowanie użyć na podstawie samej nazwy pakietu bez sprawdzenia aliasów importu.
+31
View File
@@ -0,0 +1,31 @@
---
name: safe-code-edit
description: Technika bezpiecznej edycji cudzego kodu przez agenta - minimalny diff, zasady użycia narzędzi plikowych, weryfikacja po każdej zmianie i postępowanie przy nieudanej edycji. Użyj przy każdej modyfikacji plików w repozytorium.
license: Apache-2.0
metadata:
owner: pubi-platform
version: "1.0.0"
---
# Bezpieczna edycja kodu
## Twarde zasady
1. **Czytaj przed pisaniem.** Nigdy nie wywołuj `replace_in_file` na pliku, którego nie odczytałeś
w tym przebiegu. Treść z planu nie jest dowodem na aktualny stan pliku.
2. **Najmniejszy możliwy diff.** Zmieniaj wyłącznie linie, które muszą się zmienić.
`write_file` (nadpisanie całości) jest dozwolone tylko dla plików, które sam utworzyłeś.
3. **Jedna intencja na edycję.** Nie łącz migracji API z poprawą literówki w komentarzu.
4. **Weryfikuj natychmiast.** Po zmianie pliku uruchom `run_verification`.
Jeśli wynik jest czerwony, napraw przyczynę zanim dotkniesz kolejnego pliku.
5. **Nie walcz z narzędziem.** Jeśli `replace_in_file` dwa razy nie znajdzie dopasowania -
odczytaj plik ponownie i dopasuj dokładny fragment ze spacjami. Trzecia porażka = zgłoś
`requires_human` z treścią fragmentu, zamiast nadpisywać plik w całości.
6. **Nie dotykaj plików spoza planu.** Rozszerzenie zakresu wymaga nowego planu.
## Gdy weryfikacja jest czerwona
- Przeczytaj **pełny** komunikat błędu, nie tylko ostatnią linię.
- Zlokalizuj plik i linię z traceback; napraw przyczynę, nie objaw.
- Nie modyfikuj testów, żeby przeszły. Test jest kontraktem.
- Jeśli po trzech próbach ten sam błąd - zatrzymaj się i zgłoś `requires_human` z pełnym logiem.
+52
View File
@@ -0,0 +1,52 @@
---
name: sdk-version-upgrade
description: Podniesienie wersji SDK lub biblioteki wraz z migracją wywołań API - planowanie zmiany, kolejność edycji, obsługa breaking changes i kryteria akceptacji. Użyj gdy zadanie mówi o bumpie wersji, upgradzie SDK, migracji do nowego major release lub usunięciu deprecated API.
license: Apache-2.0
allowed-tools:
- read_file
- search_repo
- list_files
- write_file
- replace_in_file
- run_verification
metadata:
owner: pubi-platform
version: "1.0.0"
references: acme-sdk-2.x-migration.md
---
# Upgrade wersji SDK
## Zasada nadrzędna
Upgrade to **dwie rozłączne zmiany**: (a) deklaracja wersji w pliku zależności,
(b) dostosowanie kodu do nowego API. Deklarację wersji ustawia deterministycznie silnik pipeline'u -
Twoim zadaniem jest wyłącznie (b). Nie edytuj ręcznie plików zależności, chyba że plan mówi inaczej.
## Kolejność pracy
1. Przeczytaj notatkę migracyjną dla docelowej wersji z katalogu `references/` tego skilla.
Jeśli brakuje notatki dla danej biblioteki - **zatrzymaj się** i zgłoś `requires_human`.
Nie migruj API "z pamięci modelu".
2. Zbuduj mapę zmian: `stary symbol -> nowy symbol -> plik(i) do zmiany`.
3. Edytuj plik po pliku, najmniejszą możliwą zmianą (`replace_in_file`, nie przepisywanie pliku).
4. Po każdym pliku uruchom weryfikację (`run_verification`). Czerwony wynik naprawiaj natychmiast,
zanim przejdziesz dalej - kumulowanie błędów uniemożliwia ustalenie przyczyny.
5. Na koniec sprawdź, czy nie zostały użycia starego API: `search_repo` po każdym symbolu z mapy.
## Breaking changes - reguły decyzyjne
| Sytuacja | Działanie |
|---|---|
| Zmiana nazwy klasy/metody, ta sama semantyka | Migruj bezpośrednio |
| Zmiana nazw parametrów | Migruj, zachowując wartości wywołań 1:1 |
| Zasób wymaga teraz zamknięcia / context managera | Użyj `with`, nie dodawaj ręcznego `close()` |
| Nowy wymagany parametr bez sensownej wartości domyślnej | `requires_human` - to decyzja biznesowa |
| Usunięta funkcjonalność bez zamiennika | `requires_human`, nie obchodź problemu własną implementacją |
## Kryteria akceptacji
- Wszystkie testy repozytorium zielone.
- Zero wystąpień starych symboli poza plikami changelog/dokumentacji.
- Diff nie zawiera zmian niezwiązanych z migracją.
- Deklarowana wersja zależności odpowiada wersji docelowej z zadania.
@@ -0,0 +1,31 @@
# Maszynowo wykonywalna wersja notatki migracyjnej acme-sdk-2.x-migration.md.
#
# Zasada architektoniczna: co da się zmigrować deterministycznie, migrujemy regułą,
# a nie modelem. LLM jest od reszty - od przypadków, których reguła nie obejmuje,
# i od oceny, czy wynik ma sens. Reguły są częścią pakietu APM, więc podlegają
# temu samemu wersjonowaniu i przeglądowi co skill.
package: acme-sdk
applies_to: ">=2.0.0,<3.0.0"
file_glob: "*.py"
rules:
- id: import-client
description: "Client -> AcmeClient w imporcie"
pattern: '\bfrom acme import Client\b'
replacement: 'from acme import AcmeClient'
- id: constructor
description: "Konstruktor: nowa nazwa klasy, endpoint -> base_url"
pattern: '\bClient\(\s*api_key=(?P<key>[^,]+),\s*endpoint=(?P<endpoint>[^)]+)\)'
replacement: 'AcmeClient(api_key=\g<key>, base_url=\g<endpoint>)'
- id: send-message
description: "send() -> messages.create() z nowymi nazwami parametrów"
pattern: '\.send\(\s*to=(?P<to>[^,]+),\s*body=(?P<body>[^)]+)\)'
replacement: '.messages.create(recipient=\g<to>, content=\g<body>)'
- id: drop-close
description: "Klient 2.x zwalnia zasoby automatycznie - close() usunięte z API"
pattern: '^[ \t]*[A-Za-z_][A-Za-z0-9_]*\.close\(\)[ \t]*\n'
replacement: ''
multiline: true
- id: response-attribute
description: "Odpowiedź jest obiektem Message, nie słownikiem"
pattern: '(?P<var>\b[a-z_][a-z0-9_]*)\[[''"]id[''"]\]'
replacement: '\g<var>.id'
@@ -0,0 +1,27 @@
# acme-sdk: migracja 1.x -> 2.x
> Przykładowa notatka migracyjna. W realnym wdrożeniu ten katalog zasila pakiet APM
> utrzymywany przez zespół właściciela SDK (np. `pubi/apm-packages/acme-sdk-migrations`),
> a `apm install` dostarcza go do joba CI z pinem po tagu.
## Zmiany łamiące kompatybilność
| 1.x | 2.x | Uwagi |
|---|---|---|
| `from acme import Client` | `from acme import AcmeClient` | zmiana wyłącznie nazwy |
| `Client(api_key=..., endpoint=...)` | `AcmeClient(api_key=..., base_url=...)` | `endpoint` -> `base_url` |
| `client.send(to=..., body=...)` | `client.messages.create(recipient=..., content=...)` | wysyłka przez sub-resource |
| `client.close()` | `with AcmeClient(...) as client:` | klient jest context managerem |
| zwracany `dict` z kluczem `id` | obiekt `Message` z atrybutem `.id` | dostęp `msg["id"]` -> `msg.id` |
## Bez zmian
- `AcmeError` pozostaje w `acme.errors`.
- Format kluczy API i semantyka retry są niezmienione.
## Kolejność migracji
1. Import i konstrukcja klienta.
2. Wywołania wysyłki.
3. Zarządzanie cyklem życia (`close()` -> `with`).
4. Odczyt pól odpowiedzi.
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Znajduje użycia modułu w repozytorium wraz z kontekstem.
# Użycie: find_usages.sh <katalog-repo> <nazwa-modulu>
set -euo pipefail
repo="${1:?podaj katalog repozytorium}"
module="${2:?podaj nazwę modułu importu}"
rg --line-number --with-filename \
-e "^\s*import\s+${module}\b" \
-e "^\s*from\s+${module}\b" \
-e "\b${module}\." \
--glob '!**/.git/**' --glob '!**/node_modules/**' \
"$repo" || echo "brak użyć modułu ${module}"
+32
View File
@@ -0,0 +1,32 @@
# ---- Backend LLM -----------------------------------------------------------
# Najprościej: nie ustawiaj tego ręcznie, tylko uruchom
# task llm:up && eval "$(task llm:env)"
# Zmienne poniżej to dokładnie to, co wygeneruje `task llm:env` z llm/models.yml.
#
# Wybór backendu: auto (domyślnie) | vllm-gpu | vllm-metal | ollama | mock
# LLM_BACKEND=ollama task llm:up
CODEMOD_MODEL_PROVIDER=vllm # vllm | openai_like | ollama
CODEMOD_LLM_BASE_URL=https://vllm.apps.ocp.internal/v1
CODEMOD_LLM_API_KEY=dummy # w CI: zmienna masked/protected
CODEMOD_MODEL_PLANNER=Qwen/Qwen3-32B
CODEMOD_MODEL_CODER=Qwen/Qwen3-Coder-30B
CODEMOD_MODEL_REVIEWER=Qwen/Qwen3-32B
CODEMOD_MODEL_SCRIBE=Qwen/Qwen3-8B
# ---- Budżety i guardraile --------------------------------------------------
CODEMOD_MAX_ITERATIONS=4
CODEMOD_MAX_FILES_CHANGED=40
CODEMOD_MAX_FILE_BYTES=400000
CODEMOD_TOOL_CALL_LIMIT=60
CODEMOD_VERIFY_TIMEOUT_S=900
# ---- GitLab ---------------------------------------------------------------
CI_SERVER_URL=https://gitlab.internal
CI_PROJECT_ID=1234
CODEMOD_GITLAB_TOKEN= # PAT/Project Access Token z api+write_repository
# ---- Lokalne uruchamianie modelu (opcjonalne) ------------------------------
# HUGGING_FACE_HUB_TOKEN= # modele za bramką na Hugging Face
# HF_CACHE=$HOME/.cache/huggingface # współdzielony cache modeli dla kontenera vLLM
# LLM_PORT=8000 # port vLLM
# LLM_WAIT_TIMEOUT=900 # ile sekund czekać na wstanie endpointu
+17
View File
@@ -0,0 +1,17 @@
__pycache__/
*.egg-info/
.venv/
.task/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# APM: artefakty instalacji zależności kontekstowych
apm_modules/
.claude/
.github/instructions/
.cursor/
# artefakty przebiegów
.runs/
workspace/
*.patch
.env
+105
View File
@@ -0,0 +1,105 @@
# Pipeline repozytorium wzorcowego.
#
# Dwa niezależne przepływy:
# 1. jakość samego silnika (lint, testy, przebieg offline na fixture, obraz runnera),
# 2. demonstracja użycia: te same joby, które repozytoria docelowe dostają przez include
# pliku .gitlab/ci/agentic-codemod.template.yml.
stages: [test, build, prepare, plan, apply]
# Joby instalują zależności wprost przez pip, a nie przez Taskfile: w obrazie
# python:3.12-slim nie ma PEP 668 ani systemowego interpretera do ochrony, a runner
# nie musi wtedy mieć go-task. Lokalnym odpowiednikiem tych trzech jobów jest `task ci`.
default:
image: python:3.12-slim
interruptible: true
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git ripgrep >/dev/null
- pip install -q -e ".[dev]"
# Formularz uruchomienia ręcznego (Build > Pipelines > Run pipeline).
# Zmienne z opisem i listą wartości renderują się jako pola formularza -
# to jest "wsad" dla sieci agentowej podawany przez człowieka.
variables:
CODEMOD_PACKAGE:
value: ""
description: "Pakiet do podniesienia, np. acme-sdk (puste = tylko testy silnika)"
CODEMOD_TO_VERSION:
value: ""
description: "Wersja docelowa, np. 2.1.0"
CODEMOD_MODULE:
value: ""
description: "Nazwa modułu importu, jeśli inna niż nazwa pakietu"
CODEMOD_CONSTRAINTS:
value: ""
description: "Dodatkowe ograniczenia z issue lub decyzji architektonicznej"
CODEMOD_ISSUE:
value: ""
description: "Numer issue do skomentowania po utworzeniu MR"
CODEMOD_MODEL_PROVIDER:
value: "vllm"
options: ["vllm", "ollama", "openai_like"]
description: "Backend LLM"
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "trigger"' # wyzwalane z issue/webhooka
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
# ---------------------------------------------------------------- jakość silnika
lint:
stage: test
script:
- ruff check src tests llm
- ruff format --check src tests llm
# Testy obejmują też ścieżkę agentową: tests/test_llm_path.py podnosi atrapę serwera
# OpenAI (llm/mock_server.py) i przepuszcza przez nią pełny przebieg z agentami.
# Bez GPU, bez pobierania modelu, tak samo na Linuksie i macOS.
unit:
stage: test
script:
- pytest -q --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xml
# Smoke test całego przepływu bez modelu językowego: kontekst APM, adaptery, sandbox,
# workflow agno i weryfikacja. Nie wymaga GPU ani dostępu do endpointu LLM,
# więc może być bramką na każdym merge requeście.
e2e-offline:
stage: test
script:
- |
agentic-codemod run \
--repo examples/fixtures/acme-app \
--package acme-sdk --module acme --to-version 2.1.0 \
--offline --run-dir "$CI_PROJECT_DIR/.runs/$CI_PIPELINE_ID"
- |
grep -q '"status": "success"' ".runs/$CI_PIPELINE_ID/run.json"
artifacts:
when: always
expire_in: 30 days
paths: [".runs/"]
runner-image:
stage: build
image: gcr.io/kaniko-project/executor:debug
before_script: []
script:
- /kaniko/executor --context "$CI_PROJECT_DIR" --dockerfile ci/Dockerfile
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
--destination "$CI_REGISTRY_IMAGE:latest"
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
# ------------------------------------------------------- demonstracja użycia szablonu
include:
- local: '/.gitlab/ci/agentic-codemod.template.yml'
inputs:
image: "python:3.12-slim"
apm_root: "."
+98
View File
@@ -0,0 +1,98 @@
# Szablon do włączenia w repozytorium docelowym:
#
# include:
# - project: 'pubi/agentic-codemod-pipeline'
# ref: 'v0.1.0'
# file: '/.gitlab/ci/agentic-codemod.template.yml'
# inputs:
# image: 'registry.internal/pubi/agentic-codemod:0.1.0'
# target_branch: 'main'
#
# Szablon dodaje trzy joby: przygotowanie kontekstu APM, plan (bez zmian w kodzie)
# oraz wdrożenie zmian za bramką manualną.
spec:
inputs:
image:
default: "registry.internal/pubi/agentic-codemod:0.1.0"
description: "Obraz runnera z zainstalowanym agentic-codemod i APM CLI"
stage_context:
default: "prepare"
stage_plan:
default: "plan"
stage_apply:
default: "apply"
target_branch:
default: "main"
description: "Gałąź docelowa merge requesta"
apm_root:
default: "."
description: "Katalog z apm.yml i .apm/ (kontekst agentowy)"
---
.codemod-base:
image: $[[ inputs.image ]]
variables:
GIT_DEPTH: "0"
CODEMOD_RUN_DIR: "$CI_PROJECT_DIR/.runs/$CI_PIPELINE_ID"
CODEMOD_APM_ROOT: $[[ inputs.apm_root ]]
before_script:
- git config --global --add safe.directory "$CI_PROJECT_DIR"
artifacts:
when: always
expire_in: 90 days
paths:
- .runs/
codemod:context:
extends: .codemod-base
stage: $[[ inputs.stage_context ]]
script:
# Kontekst agentowy jest zależnością jak każda inna: instalowany z pinów, audytowany,
# a lockfile ląduje w artefaktach jako dowód, co dokładnie dostał model.
- apm install
- apm audit
- agentic-codemod context --apm-root "$CODEMOD_APM_ROOT" --json | tee .runs/apm-context.json
artifacts:
when: always
expire_in: 90 days
paths:
- apm_modules/
- apm.lock.yaml
- .runs/
reports:
dotenv: []
codemod:plan:
extends: .codemod-base
stage: $[[ inputs.stage_plan ]]
needs: ["codemod:context"]
script:
- |
agentic-codemod run \
--repo "$CI_PROJECT_DIR" --in-place --plan-only \
--package "$CODEMOD_PACKAGE" --to-version "$CODEMOD_TO_VERSION" \
--module "${CODEMOD_MODULE:-}" --constraints "${CODEMOD_CONSTRAINTS:-}" \
--run-dir "$CODEMOD_RUN_DIR"
rules:
- if: '$CODEMOD_PACKAGE'
codemod:apply:
extends: .codemod-base
stage: $[[ inputs.stage_apply ]]
needs: ["codemod:plan"]
# Bramka człowieka przed jakąkolwiek modyfikacją kodu i przed utworzeniem MR.
# Świadomie NIE automatyzujemy tego kroku - podpis pod zmianą ma mieć właściciela.
when: manual
allow_failure: false
script:
- |
agentic-codemod run \
--repo "$CI_PROJECT_DIR" --in-place --publish \
--package "$CODEMOD_PACKAGE" --to-version "$CODEMOD_TO_VERSION" \
--module "${CODEMOD_MODULE:-}" --constraints "${CODEMOD_CONSTRAINTS:-}" \
--issue "${CODEMOD_ISSUE:-}" \
--target-branch $[[ inputs.target_branch ]] \
--run-dir "$CODEMOD_RUN_DIR"
rules:
- if: '$CODEMOD_PACKAGE'
+106
View File
@@ -0,0 +1,106 @@
# agentic-codemod-pipeline
Repozytorium wzorcowe: **sieć agentowa (agno) modyfikująca kod, zasilana kontekstem
z pakietów APM, uruchamiana jako pipeline w GitLab CI.**
Przykładowy przypadek użycia: podniesienie wersji SDK wraz z migracją wywołań API.
```bash
brew install go-task # albo: pip install go-task-bin
task install # .venv + zależności
task test # 46 testów, bez modelu językowego
task demo # pełny przebieg na fixture, offline
task --list # reszta zadań
```
Wszystko dzieje się w lokalnym `.venv` - nic nie trafia do systemowego Pythona.
Gdy coś nie działa, zacznij od `task doctor`.
## Skąd się bierze zachowanie agentów
Z pakietu APM, nie z kodu:
```
.apm/
├── agents/ scout, planner, coder, reviewer, scribe (model, narzędzia, schemat wyjścia)
├── skills/ repo-recon, sdk-version-upgrade, safe-code-edit, merge-request-authoring
├── instructions/ guardraile bezpieczeństwa, konwencje języka
├── prompts/ wsad zadania (sdk-upgrade, dependency-audit)
└── context/ kontrakt przebiegu wspólny dla wszystkich ról
```
`apm.yml` deklaruje, skąd przychodzi reszta kontekstu; `apm-policy.yml` ogranicza, co wolno
zainstalować; `apm.lock.yaml` pinuje wersje. Silnik w `src/agentic_codemod/` kompiluje
te prymitywy do obiektów agno - nie zawiera ani jednego zaszytego promptu.
## Przepływ
```
intake → recon → plan → [bump wersji → pętla(implement → verify) → review → opis MR] → bramka CI → MR
```
Kroki deterministyczne (wykrycie ekosystemu, podbicie wersji, uruchomienie testów, git)
robi kod. Model dostaje tylko to, czego nie da się zrobić inaczej. Szczegóły podziału:
[docs/architecture.md](docs/architecture.md).
## Trzy tryby, jedna topologia
| Tryb | Mózg | Zastosowanie |
|---|---|---|
| `--offline` | reguły codemod z pakietu APM | smoke test pipeline'u, bez GPU i bez internetu |
| domyślny + backend `mock` | agenci agno na atrapie serwera OpenAI | test ścieżki agentowej w CI: narzędzia, schematy, pętla weryfikacji |
| domyślny + realny model | agenci agno na vLLM | przypadki wykraczające poza reguły |
## Model językowy od zera
Nie ma jednej komendy uruchamiającej vLLM na Linuksie i na macOS - Docker na Macu
nie ma dostępu do Metala. Kontrakt jest jeden (endpoint zgodny z OpenAI), a backend
dobiera się do platformy sam:
```bash
task llm:list # co jest dostępne i co pasuje do tej maszyny
task llm:up # Linux+NVIDIA -> vLLM w kontenerze, macOS -> vllm-metal (MLX)
eval "$(task llm:env)" # zmienne dla silnika
task llm:logs # podgląd logów backendu na żywo
task demo:llm # przebieg agentowy na fixture
task llm:down
```
Wybór ręczny: `LLM_BACKEND=ollama task llm:up`, `LLM_BACKEND=mock task demo:llm`.
Konfiguracja backendów i modeli per rola: [`llm/models.yml`](llm/models.yml).
## Dokumentacja
- [Architektura](docs/architecture.md) - warstwy, przepływ, model bezpieczeństwa, granice
- [Runbook](docs/runbook.md) - uruchamianie, kody wyjścia, typowe sytuacje, rozszerzanie
- ADR: [APM jako łańcuch dostaw kontekstu](docs/adr/0001-apm-jako-lancuch-dostaw-kontekstu.md) ·
[Deterministycznie vs model](docs/adr/0002-podzial-na-krok-deterministyczny-i-model.md) ·
[Runtime w jobie CI](docs/adr/0003-runtime-w-efemerycznym-jobie-gitlab-ci.md) ·
[Guardraile w dwóch warstwach](docs/adr/0004-guardraile-w-dwoch-warstwach.md) ·
[Backend LLM per platforma](docs/adr/0005-backend-llm-per-platforma.md)
## Struktura
```
.apm/ kontekst agentowy (źródło prawdy o zachowaniu sieci)
src/agentic_codemod/
├── apm/ ładowanie prymitywów APM i kompilacja do agno
├── adapters/ pip / maven / npm - wiedza deterministyczna o ekosystemie
├── tools/ sandboxowane narzędzia agenta (pliki, weryfikacja, git)
├── workflow/ topologia agno, kroki, reguły codemod, uruchamianie
├── observability/ ślad audytowy z redakcją sekretów
└── integrations/ GitLab (merge request, komentarze)
llm/ backendy LLM: models.yml, docker-compose, vllm-metal, atrapa serwera OpenAI
examples/fixtures/acme-app aplikacja na SDK 1.x + atrapa SDK 2.x (test regresyjny e2e)
.gitlab/ci/ szablon do include w repozytoriach docelowych
ci/ obraz runnera
```
## Wymagania
Python 3.10+, git, ripgrep. Do instalacji kontekstu: APM CLI.
Do przebiegu z modelem wystarczy `task llm:up`: na Linuksie z GPU NVIDIA potrzebny
Docker, na macOS Apple Silicon - arm64 Python 3.12 i Xcode Command Line Tools.
Testy i tryb offline nie wymagają niczego z tej listy.
+382
View File
@@ -0,0 +1,382 @@
# Taskfile dla agentic-codemod-pipeline (https://taskfile.dev)
#
# Instalacja go-task:
# macOS: brew install go-task
# pip: pip install go-task-bin
# linux: sh -c "$(curl -sSL https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
#
# Wszystkie zadania pracują w lokalnym .venv. Nic nie jest instalowane do systemowego
# Pythona - na macOS z Homebrew kończyłoby się to błędem "externally-managed-environment"
# (PEP 668), a w najlepszym razie zaśmieceniem interpretera systemowego.
version: "3"
silent: true
# Lokalna konfiguracja developerska. Plik jest opcjonalny i nie trafia do repozytorium
# (.gitignore). Zmienne wyeksportowane w powłoce mają pierwszeństwo przed .env,
# więc `eval "$(task llm:env)"` zawsze wygrywa z tym, co tu wpisano.
dotenv: [".env"]
vars:
VENV: .venv
BIN: "{{.VENV}}/bin"
PY: "{{.VENV}}/bin/python"
CLI: "{{.VENV}}/bin/agentic-codemod"
FIXTURE: examples/fixtures/acme-app
# Interpreter bazowy do utworzenia venva. Na macOS z systemowym Pythonem 3.9:
# PYTHON=python3.12 task install
PYTHON: '{{.PYTHON | default "python3"}}'
# Backend LLM: auto (wykrycie platformy) | vllm-gpu | vllm-metal | ollama | mock
LLM_BACKEND: '{{.LLM_BACKEND | default "auto"}}'
LLM_RUN_DIR: .runs/llm
COMPOSE: docker compose -f llm/docker-compose.yml
BACKEND_PY: "{{.VENV}}/bin/python llm/scripts/backend.py"
env:
PYTHONDONTWRITEBYTECODE: "1"
tasks:
default:
desc: Lista dostępnych zadań
cmds:
- task --list
# ------------------------------------------------------------- środowisko
doctor:
desc: Diagnostyka środowiska - uruchom to najpierw, gdy coś nie działa
cmds:
- |
printf 'system : %s %s\n' "$(uname -s)" "$(uname -m)"
printf 'interpreter bazowy: %s -> %s\n' "{{.PYTHON}}" "$({{.PYTHON}} -V 2>&1 || echo BRAK)"
printf 'venv : %s\n' "$(test -x {{.PY}} && {{.PY}} -V 2>&1 || echo 'brak (uruchom: task install)')"
printf 'uv : %s\n' "$(command -v uv >/dev/null 2>&1 && uv --version || echo 'brak (opcjonalny, przyspiesza instalację)')"
printf 'git : %s\n' "$(git --version 2>/dev/null || echo BRAK)"
printf 'ripgrep : %s\n' "$(rg --version 2>/dev/null | head -1 || echo 'brak (opcjonalny, używany przez skille)')"
printf 'apm : %s\n' "$(apm --version 2>/dev/null || echo 'brak (potrzebny do apm install)')"
printf 'agno : %s\n' "$(test -x {{.PY}} && {{.PY}} -c 'import agno; print(agno.__version__)' 2>/dev/null || echo 'brak')"
venv:
internal: true
preconditions:
- sh: command -v {{.PYTHON}} >/dev/null 2>&1
msg: |
Nie znaleziono interpretera "{{.PYTHON}}".
Na macOS: brew install python@3.12, a potem PYTHON=python3.12 task install
- sh: '{{.PYTHON}} -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)"'
msg: |
Wymagany Python 3.10 lub nowszy ({{.PYTHON}} jest starszy).
macOS ma w systemie 3.9 - zainstaluj nowszy i wskaż go:
brew install python@3.12 && PYTHON=python3.12 task install
status:
- test -x {{.PY}}
cmds:
- |
# Niekompletny venv (przerwana instalacja, skasowany interpreter) jest gorszy
# niż jego brak - narzędzia zgłaszają wtedy mylące błędy. Odtwarzamy go od zera.
if [ -d "{{.VENV}}" ] && [ ! -x "{{.PY}}" ]; then
echo "usuwam niekompletny {{.VENV}}"
rm -rf "{{.VENV}}"
fi
if command -v uv >/dev/null 2>&1; then
uv venv --python {{.PYTHON}} {{.VENV}}
else
{{.PYTHON}} -m venv {{.VENV}}
fi
install:
desc: Tworzy .venv i instaluje zależności (runtime + dev)
deps: [venv]
status:
- test -f {{.VENV}}/.install-stamp
- test {{.VENV}}/.install-stamp -nt pyproject.toml
cmds:
- |
if command -v uv >/dev/null 2>&1; then
uv pip install --python {{.PY}} -e ".[dev]"
else
{{.PY}} -m pip install --quiet --upgrade pip
{{.PY}} -m pip install -e ".[dev]"
fi
- touch {{.VENV}}/.install-stamp
- 'echo "gotowe: {{.CLI}}"'
context:
desc: Instaluje i audytuje kontekst agentowy (APM), potem go wypisuje
deps: [install]
preconditions:
- sh: command -v apm >/dev/null 2>&1
msg: |
Brak APM CLI. Instalacja:
macOS: brew install apm (albo pip install apm-cli)
inne: curl -sSL https://aka.ms/apm-unix | sh
Repozytorium działa też bez APM CLI - wtedy używany jest wyłącznie
lokalny kontekst z .apm/ (bez zależności zewnętrznych).
cmds:
- apm install
- apm audit
- "{{.CLI}} context"
context:show:
desc: Wypisuje kontekst agentowy widziany przez silnik (bez apm install)
deps: [install]
cmds:
- "{{.CLI}} context"
# ------------------------------------------------------------------ jakość
lint:
desc: Statyczna analiza i sprawdzenie formatowania
deps: [install]
cmds:
- "{{.BIN}}/ruff check src tests llm"
- "{{.BIN}}/ruff format --check src tests llm"
fmt:
desc: Automatyczna poprawa formatowania i prostych błędów
deps: [install]
cmds:
- "{{.BIN}}/ruff check --fix src tests llm"
- "{{.BIN}}/ruff format src tests llm"
test:
desc: Testy jednostkowe i integracyjne (bez modelu językowego)
deps: [install]
cmds:
- "{{.BIN}}/pytest -q {{.CLI_ARGS}}"
test:cov:
desc: Testy z raportem pokrycia
deps: [install]
cmds:
- "{{.BIN}}/pytest -q --cov=agentic_codemod --cov-report=term-missing"
ci:
desc: To, co sprawdza pipeline - uruchom przed pushem
cmds:
- task: lint
- task: test
- task: demo
# ------------------------------------------------------------- uruchomienia
demo:
desc: Pełny przebieg offline na fixture (bez LLM, bez sieci)
deps: [install]
vars:
RUN_DIR: '{{.RUN_DIR | default ".runs/demo"}}'
cmds:
- |
{{.CLI}} run \
--repo {{.FIXTURE}} \
--package acme-sdk --module acme --to-version 2.1.0 \
--offline --run-dir "{{.RUN_DIR}}"
- 'echo "diff: {{.RUN_DIR}}/changes.patch | manifest: {{.RUN_DIR}}/run.json"'
plan:
desc: 'Sam plan zmian, bez modyfikacji kodu. Użycie: task plan -- --repo ../repo --package acme-sdk --to-version 2.1.0'
deps: [install]
cmds:
- "{{.CLI}} run --plan-only --run-dir .runs/plan {{.CLI_ARGS}}"
run:
desc: 'Przebieg z modelem. Użycie: task run -- --repo ../repo --package acme-sdk --to-version 2.1.0'
deps: [install]
preconditions:
- sh: test -n "$CODEMOD_LLM_BASE_URL"
msg: |
Nie ustawiono CODEMOD_LLM_BASE_URL. Skopiuj .env.example do .env i wyeksportuj zmienne,
albo uruchom przebieg deterministyczny: task demo
cmds:
- "{{.CLI}} run --run-dir .runs/local {{.CLI_ARGS}}"
# ------------------------------------------------------------------- LLM
# Kontrakt jest jeden: endpoint zgodny z OpenAI pod CODEMOD_LLM_BASE_URL.
# Backendy różnią się wyłącznie tym, jak ten endpoint powstaje.
# Konfiguracja: llm/models.yml. Wybór ręczny: LLM_BACKEND=ollama task llm:up
llm:list:
desc: Dostępne backendy LLM i który pasuje do tej maszyny
deps: [install]
cmds:
- "{{.BACKEND_PY}} list"
llm:env:
desc: 'Zmienne środowiskowe dla wybranego backendu. Użycie: eval "$(task llm:env)"'
deps: [install]
cmds:
- "{{.BACKEND_PY}} env --backend {{.LLM_BACKEND}}"
llm:up:
desc: Uruchamia backend LLM (autodetekcja platformy) i czeka na gotowość endpointu
deps: [install]
cmds:
- mkdir -p {{.LLM_RUN_DIR}}
- |
set -e
BACKEND="$({{.BACKEND_PY}} detect --requested {{.LLM_BACKEND}})"
MODEL="$({{.BACKEND_PY}} get serve.model --backend "$BACKEND")"
BASE_URL="$({{.BACKEND_PY}} get base_url --backend "$BACKEND")"
echo "backend: $BACKEND | model: $MODEL | endpoint: $BASE_URL"
case "$BACKEND" in
vllm-gpu)
command -v docker >/dev/null || { echo "brak dockera - zainstaluj albo: LLM_BACKEND=ollama task llm:up" >&2; exit 1; }
LLM_SERVE_MODEL="$MODEL" {{.COMPOSE}} --profile vllm-gpu up -d
LOG=""
;;
vllm-metal)
LOG="{{.LLM_RUN_DIR}}/vllm-metal.log"
llm/scripts/vllm-metal.sh up "$MODEL" 8000 "$LOG" "{{.LLM_RUN_DIR}}/vllm-metal.pid"
;;
ollama)
if command -v ollama >/dev/null 2>&1; then
# macOS: natywna Ollama korzysta z Metala; w kontenerze liczyłaby na CPU
curl -fsS --max-time 3 http://localhost:11434/api/version >/dev/null 2>&1 || {
{{.PY}} llm/scripts/daemonize.py --pidfile "{{.LLM_RUN_DIR}}/ollama.pid" \
--log "{{.LLM_RUN_DIR}}/ollama.log" -- ollama serve
sleep 2
}
ollama pull "$MODEL"
else
{{.COMPOSE}} --profile ollama up -d
{{.COMPOSE}} exec -T ollama ollama pull "$MODEL"
fi
LOG="{{.LLM_RUN_DIR}}/ollama.log"
;;
mock)
LOG="{{.LLM_RUN_DIR}}/mock.log"
{{.PY}} llm/scripts/daemonize.py --pidfile "{{.LLM_RUN_DIR}}/mock.pid" --log "$LOG" -- \
{{.PY}} llm/mock_server.py --port 8077
;;
*)
echo "nieznany backend: $BACKEND" >&2; exit 1
;;
esac
echo "podgląd logów w drugim oknie: task llm:logs"
llm/scripts/wait-for-endpoint.sh "$BASE_URL" "${LLM_WAIT_TIMEOUT:-900}" "$LOG"
echo
echo "gotowe. Wyeksportuj zmienne do swojej powłoki:"
echo ' eval "$(task llm:env)"'
llm:down:
desc: Zatrzymuje backend LLM
deps: [install]
cmds:
- |
BACKEND="$({{.BACKEND_PY}} detect --requested {{.LLM_BACKEND}})"
case "$BACKEND" in
vllm-gpu) {{.COMPOSE}} --profile vllm-gpu down ;;
ollama)
if [ -f "{{.LLM_RUN_DIR}}/ollama.pid" ]; then
{{.PY}} llm/scripts/daemonize.py --pidfile "{{.LLM_RUN_DIR}}/ollama.pid" --log /dev/null --stop
else
{{.COMPOSE}} --profile ollama down
fi
;;
vllm-metal) llm/scripts/vllm-metal.sh down "{{.LLM_RUN_DIR}}/vllm-metal.pid" ;;
mock)
{{.PY}} llm/scripts/daemonize.py --pidfile "{{.LLM_RUN_DIR}}/mock.pid" --log /dev/null --stop
;;
esac
llm:status:
desc: Czy endpoint LLM odpowiada i jakie modele wystawia
deps: [install]
cmds:
- |
BASE_URL="$({{.BACKEND_PY}} get base_url --backend {{.LLM_BACKEND}})"
echo "endpoint: $BASE_URL"
curl -fsS --max-time 5 "$BASE_URL/models" || { echo "brak odpowiedzi - uruchom: task llm:up" >&2; exit 1; }
llm:smoke:
desc: Jedno zapytanie do endpointu - sprawdza, czy model w ogóle odpowiada
deps: [install]
cmds:
- |
BASE_URL="$({{.BACKEND_PY}} get base_url --backend {{.LLM_BACKEND}})"
MODEL="$({{.BACKEND_PY}} get serve.model --backend {{.LLM_BACKEND}})"
curl -fsS --max-time 120 "$BASE_URL/chat/completions" \
-H 'Content-Type: application/json' \
-d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Odpowiedz jednym slowem: dziala\"}],\"max_tokens\":16}"
echo
llm:logs:
desc: 'Logi backendu LLM na żywo. Warianty: TAIL=500, FOLLOW=false, GREP=wzorzec'
deps: [install]
vars:
TAIL: '{{.TAIL | default "100"}}'
FOLLOW: '{{.FOLLOW | default "true"}}'
GREP: '{{.GREP | default ""}}'
cmds:
- |
BACKEND="$({{.BACKEND_PY}} detect --requested {{.LLM_BACKEND}})"
llm/scripts/logs.sh \
--backend "$BACKEND" \
--run-dir "{{.LLM_RUN_DIR}}" \
--tail "{{.TAIL}}" \
{{if eq .FOLLOW "false"}}--no-follow{{else}}--follow{{end}} \
{{if .GREP}}--grep "{{.GREP}}"{{end}}
llm:logs:errors:
desc: Same błędy i ostrzeżenia z logów backendu (jednorazowy zrzut)
deps: [install]
vars:
TAIL: '{{.TAIL | default "2000"}}'
cmds:
- |
BACKEND="$({{.BACKEND_PY}} detect --requested {{.LLM_BACKEND}})"
llm/scripts/logs.sh --backend "$BACKEND" --run-dir "{{.LLM_RUN_DIR}}" \
--tail "{{.TAIL}}" --no-follow \
--grep "error|warning|traceback|exception|failed|out of memory|cuda|refused"
llm:logs:save:
desc: 'Zrzut logów do pliku, do dołączenia w zgłoszeniu. Domyślnie .runs/llm/dump.log'
deps: [install]
vars:
TAIL: '{{.TAIL | default "5000"}}'
OUT: '{{.OUT | default ".runs/llm/dump.log"}}'
cmds:
- |
BACKEND="$({{.BACKEND_PY}} detect --requested {{.LLM_BACKEND}})"
llm/scripts/logs.sh --backend "$BACKEND" --run-dir "{{.LLM_RUN_DIR}}" \
--tail "{{.TAIL}}" --save "{{.OUT}}"
llm:mock:
desc: Atrapa serwera OpenAI na pierwszym planie (Ctrl-C kończy)
deps: [install]
cmds:
- "{{.PY}} llm/mock_server.py --port 8077 --verbose"
# ------------------------------------------------------- przebiegi z LLM
demo:llm:
desc: Przebieg agentowy na fixture z aktualnym backendem LLM (domyślnie atrapa - deterministyczny)
deps: [install]
vars:
RUN_DIR: '{{.RUN_DIR | default ".runs/demo-llm"}}'
cmds:
- task: llm:up
vars: {LLM_BACKEND: "{{.LLM_BACKEND}}"}
- |
eval "$({{.BACKEND_PY}} env --backend {{.LLM_BACKEND}})"
{{.CLI}} run \
--repo {{.FIXTURE}} \
--package acme-sdk --module acme --to-version 2.1.0 \
--run-dir "{{.RUN_DIR}}"
test:llm:
desc: Testy ścieżki agentowej na atrapie LLM (bez GPU i bez sieci)
deps: [install]
cmds:
- "{{.BIN}}/pytest -q tests/test_llm_path.py"
# -------------------------------------------------------------- sprzątanie
clean:
desc: Usuwa artefakty przebiegów i cache narzędzi
cmds:
- rm -rf .runs workspace .pytest_cache .ruff_cache .coverage htmlcov
- find . -type d -name __pycache__ -prune -exec rm -rf {} +
distclean:
desc: Jak clean, plus .venv i zainstalowany kontekst APM
deps: [clean]
cmds:
- rm -rf {{.VENV}} apm_modules *.egg-info src/*.egg-info
+24
View File
@@ -0,0 +1,24 @@
# Polityka instalacyjna APM - egzekwowana przez `apm install` / `apm audit` w jobie CI.
# Zasada: kontekst agenta jest artefaktem regulowanym tak samo jak zależność binarna.
version: 1
sources:
# Tylko wewnętrzny GitLab oraz jawnie dopuszczone repozytoria zewnętrzne.
allow:
- "gitlab.internal/pubi/apm-packages/*"
- "github.com/microsoft/apm-sample-package"
deny:
- "*"
primitives:
# Hooki wykonują kod na runnerze - w środowisku regulowanym domyślnie zabronione.
allow: [instructions, skills, prompts, agents, context]
deny: [hooks, commands]
mcp:
# Żaden serwer MCP nie wchodzi tranzytywnie bez jawnej zgody.
require_explicit_consent: true
allow: []
integrity:
require_lockfile: true
require_pinned_refs: true # tylko tagi/SHA, nigdy `main`
scan_hidden_unicode: true
audit:
fail_on_drift: true # ręczna edycja skompilowanego kontekstu = błąd pipeline'u
+35
View File
@@ -0,0 +1,35 @@
# Manifest APM (Microsoft Agent Package Manager).
# Ten plik jest JEDYNYM źródłem prawdy o kontekście, jaki dostają agenci:
# instrukcje, skille, prompty i definicje agentów. Kod w src/ jest tylko silnikiem,
# który te prymitywy kompiluje do obiektów agno.
name: agentic-codemod
version: "0.1.0"
description: Sieć agentowa modyfikująca kod (upgrade SDK, migracje API) uruchamiana w GitLab CI
author:
name: PUBI Platform Team
license: Apache-2.0
type: hybrid # instructions + skills + prompts + agents
# Harnessy, dla których APM generuje natywne pliki podczas `apm install`.
# Silnik agno czyta bezpośrednio z .apm/ oraz apm_modules/, więc lista jest
# istotna tylko dla ludzi pracujących w IDE na tym repo.
targets:
- copilot
- claude
dependencies:
apm:
# Wewnętrzne pakiety kontekstowe organizacji (git = rejestr, nie potrzeba marketplace).
# Odkomentuj i wskaż swoje repozytoria - pinowanie po tagu jest wymagane przez apm-policy.yml.
# - pubi/apm-packages/gitlab-ci-pipelines#v1.2.0
# - pubi/apm-packages/java-sdk-migrations#v0.4.1
# - pubi/apm-packages/bank-secure-coding#v2.0.0
[]
mcp:
# Serwery MCP są opcjonalne; sieć agentowa działa bez nich (narzędzia natywne agno).
[]
scripts:
# `apm run <name>` - wygodne wywołanie promptu w lokalnym harnessie developera.
sdk-upgrade: "copilot -p .apm/prompts/sdk-upgrade.prompt.md"
dependency-audit: "copilot -p .apm/prompts/dependency-audit.prompt.md"
+30
View File
@@ -0,0 +1,30 @@
# Obraz runnera dla pipeline'u agentowego.
# W środowisku regulowanym buduj go u siebie i publikuj do wewnętrznego rejestru -
# job modyfikujący kod nie powinien ciągnąć obrazu z internetu przy każdym uruchomieniu.
FROM python:3.12-slim
ARG APM_VERSION=latest
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates ripgrep curl \
&& rm -rf /var/lib/apt/lists/*
# APM CLI - dostawca kontekstu agentowego.
# W sieci wewnętrznej podmień na instalację z lustra pakietów (PIP_INDEX_URL).
RUN pip install --no-cache-dir "apm-cli${APM_VERSION:+}" || \
(echo "Instalacja apm-cli z PyPI nieudana - uzupełnij obraz o lustro wewnętrzne" && exit 1)
WORKDIR /opt/agentic-codemod
COPY pyproject.toml ./
COPY src ./src
RUN pip install --no-cache-dir .
# Runner nie pracuje na koncie root - zmiany w repozytorium mają być zwykłymi plikami.
RUN useradd --create-home --uid 1001 codemod
USER codemod
ENTRYPOINT ["agentic-codemod"]
CMD ["--help"]
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Wejście dla joba CI: zainstaluj kontekst, sprawdź go, uruchom przebieg.
# Skrypt jest celowo cienki - logika mieszka w CLI, żeby dało się ją odtworzyć lokalnie.
set -euo pipefail
: "${CODEMOD_PACKAGE:?ustaw CODEMOD_PACKAGE}"
: "${CODEMOD_TO_VERSION:?ustaw CODEMOD_TO_VERSION}"
RUN_DIR="${CODEMOD_RUN_DIR:-.runs/local}"
apm install
apm audit
agentic-codemod context --json > "${RUN_DIR}/apm-context.json" 2>/dev/null || true
exec agentic-codemod run \
--repo "${CI_PROJECT_DIR:-$PWD}" \
--in-place \
--package "${CODEMOD_PACKAGE}" \
--to-version "${CODEMOD_TO_VERSION}" \
--module "${CODEMOD_MODULE:-}" \
--constraints "${CODEMOD_CONSTRAINTS:-}" \
--run-dir "${RUN_DIR}" \
"$@"
@@ -0,0 +1,45 @@
# ADR-0001: APM jako łańcuch dostaw kontekstu agentowego
- Status: przyjęty
- Data: 2026-08-28
## Kontekst
Prompty, skille i guardraile dla agentów mają wszystkie cechy zależności: są współdzielone
między repozytoriami, zmieniają się w czasie, mają właścicieli i wpływają na wynik.
Trzymane jako pliki w repozytorium silnika stają się nierozróżnialne od kodu: nie da się ich
niezależnie wersjonować, nie da się powiedzieć "ten przebieg użył wiedzy migracyjnej w wersji 1.2.0".
## Decyzja
Kontekst agentowy dostarczamy pakietami APM (Microsoft Agent Package Manager):
`apm.yml` deklaruje zależności, `apm install` rozwiązuje je do `apm_modules/`,
`apm.lock.yaml` pinuje commity i hashe, `apm-policy.yml` ogranicza dopuszczalne źródła
i prymitywy, `apm audit` wykrywa dryf.
Silnik (Python + agno) czyta prymitywy z `.apm/` oraz `apm_modules/` i kompiluje je
do obiektów agno. Prymitywy lokalne mają pierwszeństwo przed zainstalowanymi.
## Konsekwencje
**Pozytywne**
- Zespół właściciela SDK dostarcza wiedzę migracyjną jako pakiet, bez dostępu do silnika.
- Hash lockfile'a w manifeście przebiegu odpowiada na pytanie "co dokładnie wiedział model".
- Polityka instalacyjna jest egzekwowana w CI, a nie w regulaminie.
**Negatywne**
- Dodatkowa zależność narzędziowa (APM CLI) w obrazie runnera.
- Potrzebny wewnętrzny rejestr pakietów (w praktyce: repozytoria GitLaba) i dyscyplina tagowania.
- Ryzyko rozjazdu między wersją silnika a formatem prymitywów - łagodzone testem
`test_apm_context.py`, który waliduje kontekst przy każdym MR.
## Rozważane alternatywy
- **Prompty w repozytorium silnika.** Najprostsze, ale uniemożliwia niezależne wersjonowanie
wiedzy dziedzinowej i rozmywa własność.
- **Baza wektorowa z dokumentacją migracji.** Nieodtwarzalne: dwa przebiegi na tym samym commicie
mogą dostać inny kontekst. Odpada w środowisku wymagającym audytu.
- **Własny format pakietu.** Koszt utrzymania bez zysku; APM daje lockfile, politykę i skanowanie
ukrytego Unicode z pudełka, opierając się na otwartych standardach (AGENTS.md, Agent Skills, MCP).
@@ -0,0 +1,39 @@
# ADR-0002: Deterministycznie tyle, ile się da; model tylko na resztę
- Status: przyjęty
- Data: 2026-08-28
## Kontekst
Migracja SDK to w większości mechaniczne podmiany. Powierzenie ich modelowi kosztuje tokeny,
wydłuża przebieg i wprowadza wariancję tam, gdzie wariancja jest czystą stratą -
nikt nie chce, żeby dwa uruchomienia dawały inny diff dla tej samej zmiany nazwy metody.
## Decyzja
Dzielimy pracę wzdłuż linii "czy da się to zrobić kodem":
1. Wykrycie ekosystemu, plików zależności i komendy testowej - adapter (`adapters/`).
2. Podbicie deklaracji wersji - adapter.
3. Migracja objęta regułami - silnik reguł czytający `*.codemod.yaml` z pakietu APM.
4. Reszta - agent `coder` z LLM.
5. Ocena wyniku - agent `reviewer` **oraz** niezależna kontrola mechaniczna.
Reguły codemod są częścią pakietu APM (leżą obok notatki migracyjnej), więc podlegają
temu samemu przeglądowi i wersjonowaniu co wiedza dla modelu.
## Konsekwencje
**Pozytywne**
- Powstaje tryb `--offline`: ta sama topologia bez modelu. Smoke test całego pipeline'u
na każdym MR, bez GPU i bez internetu.
- Punkt odniesienia: widać, ile przypadków model faktycznie dołożył ponad reguły.
- Mniejszy koszt i krótszy przebieg dla typowych migracji.
**Negatywne**
- Dwie ścieżki do utrzymania (`RuleBrain`, `LlmBrain`) i wspólny kontrakt między nimi.
- Reguły regexowe mają znane ograniczenia (zmiany strukturalne, wieloliniowe konteksty).
Świadomie nie budujemy własnego silnika AST - od tego są narzędzia dziedzinowe
(OpenRewrite, jscodeshift), które można podpiąć jako kolejny adapter.
@@ -0,0 +1,38 @@
# ADR-0003: Runtime w efemerycznym jobie GitLab CI, nie w usłudze
- Status: przyjęty
- Data: 2026-08-28
## Kontekst
agno pozwala wystawić agentów jako usługę (AgentOS/FastAPI) z bazą sesji, pamięcią i UI.
Kuszące, ale dla zadania "zmodyfikuj kod i otwórz MR" oznacza nową usługę produkcyjną:
własne SLA, uwierzytelnianie, przechowywanie stanu, przegląd bezpieczeństwa i dostęp
do repozytoriów z długożyjącego procesu.
## Decyzja
Runtime to efemeryczny job GitLab CI. Jedno uruchomienie = jeden job = jeden katalog roboczy
= jeden komplet artefaktów. Stan przebiegu żyje w artefaktach (`run.json`, `trace.jsonl`,
`changes.patch`), nie w bazie. Uprawnienia to uprawnienia joba (token projektowy),
a nie konta usługowego z dostępem do wszystkiego.
Wyzwalanie: issue z labelką (webhook → trigger token), zdarzenie na MR, harmonogram
oraz ręczny formularz w "Run pipeline" (zmienne z `description` i `options`).
## Konsekwencje
**Pozytywne**
- Zero nowej usługi do utrzymania i przeglądu bezpieczeństwa.
- Naturalna izolacja: brak stanu współdzielonego między przebiegami.
- Bramka manualna w CI daje właściciela podpisu pod zmianą.
- Limity zasobów i czasu przychodzą z platformy CI.
**Negatywne**
- Brak pamięci między przebiegami - powtórka zaczyna od zera (świadomy kompromis:
pamięć między przebiegami w narzędziu modyfikującym kod to więcej ryzyka niż korzyści).
- Zimny start: instalacja kontekstu APM w każdym przebiegu (łagodzone cache'em i artefaktami).
- Brak interaktywnego trybu "zapytaj człowieka w trakcie" - stąd wzorzec `requires_human`:
agent nie pyta, tylko oznacza i idzie dalej.
@@ -0,0 +1,36 @@
# ADR-0004: Guardraile w dwóch warstwach - prompt nie jest zabezpieczeniem
- Status: przyjęty
- Data: 2026-08-28
## Kontekst
Instrukcje w promptcie ("nie modyfikuj .gitlab-ci.yml") działają w większości przypadków,
ale są miękkie: zależą od modelu, długości kontekstu i sformułowania zadania.
W repozytorium bankowym pytanie audytora nie brzmi "czy model zwykle tego nie robi",
tylko "co się stanie, jeśli spróbuje".
## Decyzja
Każdy istotny guardrail istnieje dwukrotnie:
- jako instrukcja APM dla modelu (`security.instructions.md`) - żeby agent w ogóle nie próbował,
- jako mechanizm w kodzie (`WorkspaceTools`, `Settings.deny_globs`, budżety, brak powłoki) -
żeby próba zakończyła się błędem narzędzia zapisanym w śladzie audytowym.
Ta sama zasada dotyczy oceny wyniku: werdykt agenta `reviewer` jest uzupełniany niezależną
kontrolą mechaniczną (pliki objęte zakazem, zmiany w testach, budżet zakresu, status weryfikacji).
## Konsekwencje
**Pozytywne**
- Naruszenie guardraila jest zdarzeniem obserwowalnym, a nie niewidoczną zmianą w diffie.
- Da się odpowiedzieć na pytanie "co system uniemożliwia", a nie tylko "o co prosi".
- Testy zabezpieczeń (`test_workspace_tools.py`) są testami jednostkowymi, nie ćwiczeniem z promptowania.
**Negatywne**
- Duplikacja reguł w dwóch miejscach - trzeba je świadomie utrzymywać razem.
- Zbyt ciasne budżety potrafią zablokować poprawną, ale szeroką zmianę; wartości są
konfigurowalne przez zmienne środowiskowe i powinny być dostrajane per klasa repozytoriów.
@@ -0,0 +1,73 @@
# ADR-0005: Backend LLM wybierany per platforma, atrapa jako test double
- Status: przyjęty
- Data: 2026-08-29
## Kontekst
Docelowo pipeline korzysta z vLLM wystawionego na OpenShifcie. Do pracy nad samym
pipeline'em potrzebny jest jednak model uruchamialny lokalnie - i tu kończy się
jednorodność: **nie istnieje jedna komenda uruchamiająca vLLM na Linuksie i na macOS**.
- Linux z GPU NVIDIA: kontener `vllm/vllm-openai`, ścieżka najbliższa produkcji.
- macOS na Apple Silicon: Docker nie ma dostępu do Metala, więc kontener odpada.
Zostaje natywny plugin społecznościowy `vllm-project/vllm-metal` (backend MLX),
instalowany skryptem do własnego venva, wymagający arm64 Pythona 3.12 i Xcode CLT.
- Maszyna bez GPU i bez Apple Silicon: żaden z powyższych nie ma sensu.
Osobny problem: testy. Tryb `--offline` sprawdza `RuleBrain`, ale nie dotyka tego,
co jest w tym projekcie najbardziej kruche - budowy agentów z definicji APM,
wywoływania narzędzi przez model i parsowania strukturalnych wyjść. Uzależnienie
tego testu od GPU oznaczałoby, że nie uruchomi go nikt poza jedną maszyną.
## Decyzja
**Kontrakt jest jeden: endpoint zgodny z API OpenAI pod `CODEMOD_LLM_BASE_URL`.**
Backendy różnią się wyłącznie tym, jak ten endpoint powstaje. Konfiguracja mieszka
w `llm/models.yml` (jedno źródło prawdy: adresy, modele per rola, argumenty serwera),
a `task llm:up` wykrywa platformę i uruchamia właściwy wariant:
| Backend | Kiedy | Jak |
|---|---|---|
| `vllm-gpu` | Linux + NVIDIA + Docker | `docker compose --profile vllm-gpu` |
| `vllm-metal` | macOS Apple Silicon | natywny plugin MLX, `vllm serve` |
| `ollama` | awaryjnie, obie platformy | natywnie (Metal) lub kontener |
| `mock` | testy, CI | `llm/mock_server.py`, stdlib, bez pobierania |
Atrapa (`mock`) to **test double, nie symulator modelu**: odpowiada z góry ustalonymi
wywołaniami narzędzi i strukturami zestrojonymi z fixture'em `acme-app`, a routing
odpowiedzi opiera na nazwie agenta wstrzykiwanej przez agno do komunikatu systemowego.
Dzięki niej `tests/test_llm_path.py` przepuszcza pełny przebieg przez `LlmBrain`
na każdym MR, bez GPU i bez sieci.
## Konsekwencje
**Pozytywne**
- Projekt startuje od zera na Linuksie i na macOS jedną komendą; różnice platformowe
są schowane za `task llm:up`, a nie rozsypane po README.
- Ścieżka agentowa jest testowana automatycznie, nie tylko ręcznie na czyjejś maszynie.
- Podmiana modelu lub backendu to zmiana w `llm/models.yml`, nie w kodzie.
- Wszystkie backendy wymuszają `--enable-auto-tool-choice` z parserem `hermes`;
bez tego vLLM zwraca opis wywołania w treści zamiast `tool_calls` i agent `coder`
nie tknąłby żadnego pliku.
**Negatywne**
- Cztery ścieżki uruchomieniowe do utrzymania. Ograniczamy koszt tym, że wiedza
o nich jest w jednym pliku YAML plus jednym skrypcie, a nie w zadaniach.
- `vllm-metal` jest pluginem społecznościowym - może się rozjechać z upstreamem vLLM.
Dlatego Ollama zostaje jako wariant awaryjny na macOS.
- Atrapa jest zestrojona z fixture'em: zmiana fixture'u wymaga aktualizacji scenariusza.
To świadomy koszt - alternatywą jest brak testu tej ścieżki.
- Model 4B wystarcza do sprawdzenia hydrauliki, ale nie do realnych migracji.
Do pracy trzeba większego - stąd tabela rekomendacji per rola w `llm/models.yml`.
## Rozważane alternatywy
- **Tylko vLLM, macOS niech używa zdalnego endpointu.** Odpada: uniemożliwia pracę
offline i uzależnia każdego developera od środowiska współdzielonego.
- **Tylko Ollama.** Prostsze, ale rozjeżdża się z produkcją (inne API tool-callingu,
inne kwantyzacje), więc testy przestałyby cokolwiek mówić o zachowaniu na vLLM.
- **Brak atrapy, testy tylko na realnym modelu.** Niedeterministyczne i niedostępne
w CI; test, który czasem przechodzi, jest gorszy niż brak testu.
+175
View File
@@ -0,0 +1,175 @@
# Architektura sieci agentowej modyfikującej kod
## 1. Problem
Zmiany typu "podnieś wersję SDK w 60 repozytoriach" są mechanicznie proste, ale kosztowne:
każde repozytorium ma inny układ, inne miejsca użycia biblioteki i inny poziom pokrycia testami.
Klasyczna automatyzacja (skrypt + sed) załatwia 70% przypadków i zostawia najgorsze 30%.
Agent z LLM załatwia pozostałe 30%, ale wprowadza trzy nowe problemy: niepowtarzalność,
brak audytowalności i nieograniczony zakres zmian.
Ten projekt jest odpowiedzią na pytanie: **jak wpuścić agenta do repozytorium tak,
żeby dało się to pokazać audytorowi.**
## 2. Trzy warstwy i jedna zasada
```
┌───────────────────────────────────────────────────────────────────┐
│ KONTEKST — APM (Microsoft Agent Package Manager) │
│ apm.yml + .apm/{instructions,skills,prompts,agents,context} │
│ Wersjonowany, pinowany lockfile'em, audytowany polityką. │
└──────────────────────────────┬────────────────────────────────────┘
│ apm install → apm_modules/
┌──────────────────────────────▼────────────────────────────────────┐
│ RUNTIME — agno Workflow │
│ Kompilacja prymitywów APM → Agent/Skills/Steps. │
│ Deterministyczne kroki + sandboxowane narzędzia. │
└──────────────────────────────┬────────────────────────────────────┘
│ agentic-codemod run
┌──────────────────────────────▼────────────────────────────────────┐
│ EGZEKUCJA — GitLab CI │
│ Efemeryczny job, bramka manualna, artefakty audytowe, MR. │
└───────────────────────────────────────────────────────────────────┘
```
Zasada nadrzędna: **kontekst jest artefaktem, nie kodem**. Zmiana zachowania sieci agentowej
(nowa reguła migracji, ostrzejszy guardrail, inny model dla roli) to podbicie wersji pakietu APM,
a nie merge request do Pythona. Dzięki temu zespół właściciela SDK może dostarczyć wiedzę
migracyjną, nie mając dostępu do silnika pipeline'u.
## 3. Przepływ
```mermaid
flowchart TD
T[Trigger: issue z labelką / MR / harmonogram / formularz] --> C[apm install + apm audit]
C --> I[intake: deterministyczny wsad zadania]
I --> R[recon: fakty o repozytorium]
R --> P[plan: ChangePlan z kryteriami akceptacji]
P --> G{jest co wdrażać?}
G -- nie --> M[manifest + komentarz w issue]
G -- tak --> B[bump wersji: deterministyczny]
B --> L[pętla: implement → verify]
L -- czerwono, iteracja < limit --> L
L -- zielono --> V[review: recenzja diffa]
V --> S[scribe: opis merge requesta]
S --> H{bramka manualna w CI}
H -- zatwierdzone --> MR[branch + commit + push + MR]
H -- nie --> A[artefakty do wglądu, zero zmian zdalnych]
```
### Role w sieci
| Agent | Model | Narzędzia | Wyjście |
|---|---|---|---|
| `scout` | planner | odczyt, wyszukiwanie | `RepoProfile` |
| `planner` | planner | odczyt, wyszukiwanie | `ChangePlan` |
| `coder` | coder | odczyt, edycja, weryfikacja | zmieniony kod |
| `reviewer` | reviewer | odczyt, diff | `ReviewVerdict` |
| `scribe` | scribe | diff | `MergeRequestDraft` |
Definicje ról leżą w `.apm/agents/*.agent.md`. Kod nie zna nazw agentów - czyta je z kontekstu.
## 4. Co jest deterministyczne, a co należy do modelu
To jest najważniejsza decyzja projektowa. Model dostaje wyłącznie tę część pracy,
której nie da się zrobić inaczej.
| Krok | Wykonawca | Dlaczego |
|---|---|---|
| Wykrycie ekosystemu, plików zależności, komendy testowej | kod (`adapters/`) | jednoznaczne, sprawdzalne |
| Podbicie deklaracji wersji | kod (`adapters/`) | zero powodów, by ryzykować halucynację w pliku zależności |
| Migracja objęta regułami codemod | kod (`workflow/codemod.py`) | reguły z pakietu APM, w pełni powtarzalne |
| Nietypowe użycia API, kontekst biznesowy | model (`coder`) | tu klasyczna automatyzacja się kończy |
| Ocena zakresu i ryzyka zmiany | model (`reviewer`) + kontrola mechaniczna | dwa niezależne spojrzenia |
| Uruchomienie testów | kod (`tools/verification.py`) | agent nie dostaje powłoki |
| Branch, commit, push, MR | kod (`workflow/runner.py`) | jednoznaczny autor i format historii |
Konsekwencja: pipeline ma **dwa tryby o identycznej topologii**. Tryb `--offline` używa
wyłącznie reguł (`RuleBrain`), tryb domyślny dokłada agentów (`LlmBrain`). Tryb offline jest
bramką jakości na każdym MR - sprawdza cały przepływ bez kosztu GPU i bez dostępu do modelu.
## 5. Model bezpieczeństwa
Guardraile istnieją w dwóch warstwach, bo prompt nie jest zabezpieczeniem.
**Warstwa promptowa** (`.apm/instructions/security.instructions.md`) - dla modelu:
zakaz zmian w plikach pipeline'u, zakaz sekretów, zakaz rozszerzania zakresu,
zakaz usuwania testów, zakaz nowych zależności.
**Warstwa egzekucji** (`tools/workspace.py`, `config.py`) - dla audytora:
- każda ścieżka rozwiązywana względem korzenia repozytorium; wyjście przez `..` i dowiązania niemożliwe,
- lista `DEFAULT_DENY_GLOBS` blokuje zapis do `.gitlab-ci.yml`, `.git/`, `Dockerfile*`, `*.pem`, `.env*`, manifestów APM,
- budżety: liczba zmienionych plików, rozmiar pliku, liczba wywołań narzędzi, limit czasu weryfikacji,
- agent nie ma powłoki - jedyna operacja wykonawcza to `run_verification` z komendą ustaloną przez adapter,
- środowisko weryfikacji jest czyszczone ze zmiennych zawierających `TOKEN`, `SECRET`, `PASSWORD`, `API_KEY`,
- każde wywołanie narzędzia trafia do `trace.jsonl` z redakcją sekretów.
**Warstwa dostawy kontekstu** (`apm-policy.yml`): dozwolone źródła pakietów, zakaz prymitywu
`hooks` (wykonuje kod na runnerze), wymagane piny po tagu, wymagany lockfile, `apm audit`
wykrywający ręczną edycję zainstalowanego kontekstu.
## 6. Artefakty przebiegu
Każde uruchomienie zostawia w `--run-dir` komplet dowodów:
| Plik | Zawartość |
|---|---|
| `run.json` | manifest: zadanie, profil, plan, weryfikacje, werdykt, modele, hash lockfile'a APM |
| `plan.json` | plan zmian z kryteriami akceptacji i pozycjami `requires_human` |
| `profile.json` | ustalone fakty o repozytorium |
| `review.json` | werdykt recenzenta z findingami |
| `merge_request.md` | tytuł i opis MR |
| `changes.patch` | pełny diff |
| `trace.jsonl` | każde wywołanie narzędzia z czasem i wynikiem |
To jest odpowiedź na pytanie audytora "na jakiej podstawie ta zmiana weszła do repozytorium".
## 7. Backend LLM
Silnik zna wyłącznie jeden kontrakt: **endpoint zgodny z API OpenAI pod `CODEMOD_LLM_BASE_URL`**.
Cała wiedza o dostawcy jest w `llm.py` (fabryka modeli) i `llm/models.yml` (adresy, modele per rola).
Docelowo jest to vLLM na OpenShifcie. Lokalnie nie ma jednej ścieżki dla obu systemów -
Docker na macOS nie ma dostępu do Metala - więc `task llm:up` wykrywa platformę:
| Backend | Kiedy | Mechanizm |
|---|---|---|
| `vllm-gpu` | Linux + NVIDIA + Docker | `vllm/vllm-openai` przez docker compose |
| `vllm-metal` | macOS Apple Silicon | plugin `vllm-project/vllm-metal` (MLX), natywny `vllm serve` |
| `ollama` | awaryjnie, obie platformy | `/v1` zgodne z OpenAI, natywnie lub w kontenerze |
| `mock` | testy i CI | `llm/mock_server.py` - stdlib, zero pobierania |
Każdy backend serwujący realny model wymusza `--enable-auto-tool-choice` z parserem `hermes`.
Bez tego vLLM zwraca opis wywołania w treści odpowiedzi zamiast `tool_calls`, agent `coder`
nie tknąłby żadnego pliku, a przebieg kończyłby się "sukcesem" bez jednej zmiany - awaria cicha,
czyli najgorszy rodzaj.
### Atrapa jako test double
`mock` nie jest symulatorem modelu. Odpowiada z góry ustalonymi wywołaniami narzędzi
i strukturami zestrojonymi z fixture'em `acme-app`, a rozpoznaje pytającego po nazwie agenta,
którą agno wstrzykuje do komunikatu systemowego. Dzięki temu `tests/test_llm_path.py`
przepuszcza pełny przebieg przez `LlmBrain` - budowę agentów z definicji APM, tool-calling,
parsowanie strukturalnych wyjść i pętlę weryfikacji - na każdym MR, bez GPU i bez sieci.
Podział odpowiedzialności testów jest więc taki:
| Co jest sprawdzane | Czym |
|---|---|
| reguły, adaptery, sandbox, workflow | tryb `--offline` (`RuleBrain`) |
| agenci, narzędzia, schematy, pętla | tryb `llm` na atrapie (`LlmBrain` + `mock`) |
| jakość migracji na nietypowym kodzie | realny model, ręcznie |
## 8. Granice rozwiązania
- **Bez agenta-nadzorcy.** Topologia jest jawnym przepływem (agno `Workflow`), a nie
swobodną delegacją między agentami. W zamian za mniejszą elastyczność dostajemy
przewidywalny koszt, powtarzalny ślad i możliwość wstawienia bramki między krokami.
- **Bez AgentOS.** Runtime to efemeryczny job CI. Nie utrzymujemy usługi z pamięcią sesji -
stan przebiegu żyje w artefaktach.
- **Bez dostępu do sieci z poziomu agenta.** Cała wiedza migracyjna przychodzi w pakiecie APM.
Jeśli notatki migracyjnej nie ma, pipeline zatrzymuje się i zgłasza `requires_human`,
zamiast migrować "z pamięci modelu".
- **Nie każde repozytorium się nadaje.** Bez działającej komendy testowej weryfikacja nic nie znaczy,
a bez weryfikacji ten pipeline nie ma prawa niczego publikować.
+178
View File
@@ -0,0 +1,178 @@
# Runbook
## Uruchomienie lokalne
Zadania uruchamia [go-task](https://taskfile.dev): `brew install go-task` na macOS,
`pip install go-task-bin` gdziekolwiek indziej.
```bash
task doctor # co jest w środowisku, czego brakuje
task install # .venv + zależności (runtime i dev)
task test # 46 testów, bez LLM
task demo # pełny przebieg offline na fixture
task ci # lint + test + demo, czyli to co sprawdza pipeline
```
Wynik przebiegu: `.runs/demo/run.json`, `changes.patch`, `merge_request.md`, `trace.jsonl`.
### macOS - trzy rzeczy, które potrafią zaskoczyć
1. **`externally-managed-environment` (PEP 668).** Homebrew blokuje `pip install` do interpretera
systemowego. Dlatego `task install` zawsze tworzy `.venv` i instaluje tylko do niego.
Nigdy nie uruchamiaj `pip install -e .` na `python3` z systemu.
2. **Systemowy Python to 3.9.** Za stary. `brew install python@3.12`, potem
`PYTHON=python3.12 task install` - `task doctor` powie, jaka wersja jest widoczna.
3. **Weryfikacja repozytorium uruchamia się interpreterem z `.venv`** (`sys.executable`),
a nie `python3` z `PATH`. Repozytorium docelowe z własnym środowiskiem budowania
nadpisuje komendę przez `--verify-command`.
## Model językowy lokalnie
```bash
task llm:list # backendy i ten wykryty dla tej maszyny
task llm:up # uruchomienie + czekanie na gotowość endpointu
task llm:status # czy odpowiada i jakie modele wystawia
task llm:smoke # jedno zapytanie kontrolne
task llm:down
```
### Logi
Jedno wejście niezależnie od tego, czy backend siedzi w kontenerze (`vllm-gpu`),
czy jest procesem z plikiem logu (`vllm-metal`, `ollama`, `mock`):
```bash
task llm:logs # na żywo, ostatnie 100 linii kontekstu
task llm:logs TAIL=500 # więcej historii
task llm:logs FOLLOW=false # jednorazowy zrzut zamiast śledzenia
task llm:logs GREP=throughput # filtr (wyrażenie regularne, bez rozróżniania wielkości liter)
task llm:logs:errors # tylko błędy i ostrzeżenia
task llm:logs:save OUT=/tmp/vllm.log # zrzut do pliku, np. do zgłoszenia
```
Ctrl-C kończy śledzenie normalnie. Pliki logów żyją w `.runs/llm/`.
Czego szukać w logach vLLM:
| Linia | Znaczenie |
|---|---|
| `Downloading ... safetensors` | pobieranie modelu; przy pierwszym starcie to zwykle najdłuższy etap |
| `Application startup complete` | serwer gotowy, endpoint odpowiada |
| `Avg prompt throughput ... Avg generation throughput` | przebieg pracuje; zera przez dłuższą chwilę oznaczają, że nikt nie pyta |
| `GPU KV cache usage` | rosnące do 100% zwiastuje kolejkowanie żądań |
| `CUDA out of memory` | zmniejsz `--max-model-len` albo `VLLM_GPU_UTIL`, albo weź mniejszy model |
| `ValueError: ... tool call parser` | zła wartość `--tool-call-parser` dla tego modelu |
Przy atrapie (`mock`) log pokazuje przebieg rozmowy z agentami - po jednej linii
na żądanie, z nazwą roli i wywoływanym narzędziem. To najszybszy sposób, żeby
zobaczyć, w którym kroku pipeline utknął.
| Backend | Warunki | Uwagi |
|---|---|---|
| `vllm-gpu` | Linux, GPU NVIDIA, Docker | pierwsze uruchomienie pobiera model (kilka GB), `start_period` w healthchecku to 5 minut |
| `vllm-metal` | macOS Apple Silicon, arm64 Python 3.12, Xcode CLT | plugin instaluje się do `~/.venv-vllm-metal` przy pierwszym `task llm:up` |
| `ollama` | `ollama` w PATH albo Docker | wariant awaryjny; na macOS instaluj natywnie, w kontenerze liczy na CPU |
| `mock` | nic | atrapa z `llm/mock_server.py`, deterministyczna, zestrojona z fixture'em |
Wybór ręczny: `LLM_BACKEND=ollama task llm:up`. Konfiguracja: `llm/models.yml`.
### Zdalny endpoint (np. vLLM na OpenShifcie)
```bash
export CODEMOD_MODEL_PROVIDER=vllm
export CODEMOD_LLM_BASE_URL=https://vllm.apps.ocp.internal/v1
export CODEMOD_LLM_API_KEY=... # w CI: zmienna masked
export CODEMOD_MODEL_CODER=Qwen/Qwen3-Coder-30B
```
## Przebieg z modelem
```bash
eval "$(task llm:env)" # przy backendzie lokalnym
task run -- \
--repo /sciezka/do/repo \
--package acme-sdk --module acme --to-version 2.1.0
```
Najpierw `task plan -- --repo ... --package ... --to-version ...`. Plan czyta się szybciej niż diff.
## Kody wyjścia
| Kod | Status | Znaczenie |
|---|---|---|
| 0 | `success` / `no_changes` | zmiana gotowa albo nie było czego zmieniać |
| 1 | `failed` | weryfikacja czerwona lub błąd przebiegu |
| 3 | `blocked` | recenzent zgłosił problem blokujący - wymagana decyzja człowieka |
## Wyzwalanie w GitLabie
1. **Formularz** - Build > Pipelines > Run pipeline, pola `CODEMOD_PACKAGE`, `CODEMOD_TO_VERSION`.
2. **Issue z labelką** - webhook na zdarzenie issue → trigger token → pipeline ze zmiennymi z tytułu issue.
3. **Harmonogram** - audyt zależności (`--prompt dependency-audit --plan-only`) raz w tygodniu.
4. **Include w repozytorium docelowym** - patrz `.gitlab/ci/agentic-codemod.template.yml`.
Job `codemod:apply` jest **manualny**. To jest bramka, nie niedopatrzenie.
## Typowe sytuacje
**`EcosystemNotDetected`** - repozytorium nie ma pliku markerowego (`pyproject.toml`, `pom.xml`,
`package.json`). Dopisz adapter w `adapters/` albo wskaż komendę weryfikacji przez `--verify-command`.
**`Brak skilli w kontekście APM`** - nie wykonano `apm install` albo pakiet nie jest zadeklarowany
w `apm.yml`. Job `codemod:context` jest bramką właśnie na taką sytuację.
**Status `blocked`** - przeczytaj `review.json`. Findingi `severity: blocker` opisują konkretny problem;
`requires_human` w `plan.json` mówi, czego agent świadomie nie ruszył.
**Agent nie wywołuje żadnych narzędzi, tylko opisuje co by zrobił** - serwer nie ma włączonego
tool-callingu. vLLM wymaga `--enable-auto-tool-choice --tool-call-parser hermes` (jest w `llm/models.yml`);
przy zdalnym endpointcie sprawdź to po stronie wdrożenia.
**`task llm:up` kończy się timeoutem** - pobieranie modelu trwa dłużej niż `LLM_WAIT_TIMEOUT`.
Zajrzyj do `.runs/llm/*.log` albo `task llm:logs`; zwiększ limit: `LLM_WAIT_TIMEOUT=1800 task llm:up`.
**macOS: `vllm-metal` nie chce się zainstalować** - wymaga natywnego arm64 Pythona 3.12
(Rosetta nie wystarczy) i Xcode Command Line Tools (`xcode-select --install`).
Wariant awaryjny: `LLM_BACKEND=ollama task llm:up`.
**Weryfikacja czerwona po limicie iteracji** - `changes.patch` zawiera stan po ostatniej próbie.
Zwykle oznacza, że migracja wykracza poza wiedzę z pakietu APM. Uzupełnij notatkę migracyjną
i regułę codemod, zamiast podnosić limit iteracji.
**Nie ma deklaracji pakietu do podbicia** - `profile.json` w polu `gaps`. Sprawdź, czy wersja nie jest
pinowana w pliku lock albo w obrazie bazowym.
## Dostrajanie budżetów
| Zmienna | Domyślnie | Kiedy zmieniać |
|---|---|---|
| `CODEMOD_MAX_ITERATIONS` | 4 | rzadko - wysoki limit maskuje braki w kontekście |
| `CODEMOD_MAX_FILES_CHANGED` | 40 | monorepo z rozlanymi użyciami |
| `CODEMOD_TOOL_CALL_LIMIT` | 60 | duże repozytoria wymagające dużo rozpoznania |
| `CODEMOD_VERIFY_TIMEOUT_S` | 900 | wolne buildy (Maven, testy integracyjne) |
## Zmiana modelu
Modele per rola są w `llm/models.yml`, sekcja `models` danego backendu. Kolejność
inwestowania w większy model: `coder` (stabilne tool-calling), potem `planner`
(rozumowanie o zakresie), na końcu `reviewer`. `scribe` może zostać najmniejszy.
Po zmianie: `eval "$(task llm:env)"` w bieżącej powłoce.
## Dodanie nowego ekosystemu
1. Nowa klasa w `adapters/` dziedzicząca po `EcosystemAdapter` (5 metod).
2. Rejestracja w `adapters/__init__.py`.
3. Test w `test_adapters.py` na odczyt i podbicie wersji (`task test`).
4. Opcjonalnie: instrukcja APM z konwencjami języka (`.apm/instructions/`).
Warstwa agentowa nie wymaga zmian.
## Dodanie migracji nowego SDK
1. Notatka migracyjna dla człowieka i modelu: `references/<pakiet>-<wersja>-migration.md`.
2. Reguły maszynowe: `references/<pakiet>-<wersja>-migration.codemod.yaml`.
3. Publikacja jako pakiet APM z tagiem, dopisanie do `apm.yml` repozytoriów docelowych.
Nic w Pythonie się nie zmienia.
+10
View File
@@ -0,0 +1,10 @@
# acme-app (fixture)
Minimalna aplikacja używająca `acme-sdk` w wersji **1.4.2**. Katalog `stubs/acme`
zawiera atrapę SDK w wersji **2.1.0** - tylko nowe API.
Stan początkowy: `python3 -m pytest -q` jest **czerwony** (kod woła API z 1.x).
Zadaniem pipeline'u agentowego jest doprowadzić go do zieleni, podnosząc przy okazji
deklarację wersji w `pyproject.toml`.
Nie edytuj tego katalogu ręcznie - jest punktem odniesienia dla testów regresyjnych.
+13
View File
@@ -0,0 +1,13 @@
"""Fixture nie ściąga nic z sieci: `acme` to lokalny stub udający SDK w wersji 2.x.
Dzięki temu weryfikacja jest czerwona przed migracją i zielona po niej,
a cały przebieg pipeline'u da się odtworzyć offline - także na runnerze bez internetu.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).parent
for extra in (ROOT / "src", ROOT / "stubs"):
if str(extra) not in sys.path:
sys.path.insert(0, str(extra))
+12
View File
@@ -0,0 +1,12 @@
[project]
name = "acme-app"
version = "0.3.0"
description = "Przykładowa aplikacja korzystająca z acme-sdk 1.x - fixture dla pipeline'u agentowego"
requires-python = ">=3.10"
dependencies = [
"acme-sdk==1.4.2",
"httpx>=0.27",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -0,0 +1,3 @@
"""Przykładowa aplikacja korzystająca z acme-sdk."""
__all__ = ["notifier"]
@@ -0,0 +1,27 @@
"""Wysyłka powiadomień do klientów przez acme-sdk."""
from __future__ import annotations
from acme import Client
from acme.errors import AcmeError
WELCOME_BODY = "Witamy w serwisie. Twoje konto jest aktywne."
def send_welcome(email: str, api_key: str, endpoint: str) -> str:
"""Wysyła powiadomienie powitalne i zwraca identyfikator wiadomości."""
client = Client(api_key=api_key, endpoint=endpoint)
result = client.send(to=email, body=WELCOME_BODY)
client.close()
return result["id"]
def send_reminder(email: str, api_key: str, endpoint: str, body: str) -> str | None:
"""Wysyła przypomnienie. Zwraca None, gdy dostawca odrzucił wiadomość."""
client = Client(api_key=api_key, endpoint=endpoint)
try:
result = client.send(to=email, body=body)
except AcmeError:
return None
client.close()
return result["id"]
@@ -0,0 +1,51 @@
"""Atrapa acme-sdk 2.1.0 - wyłącznie API z wersji 2.x.
Odpowiada SDK dostarczanemu przez dostawcę: klasa `Client` i metoda `send()` z 1.x
zostały usunięte, więc kod sprzed migracji nie zaimportuje się ani nie zadziała.
"""
from __future__ import annotations
from dataclasses import dataclass
from .errors import AcmeError
__version__ = "2.1.0"
@dataclass
class Message:
id: str
recipient: str
content: str
class _Messages:
def __init__(self, client: "AcmeClient") -> None:
self._client = client
def create(self, recipient: str, content: str) -> Message:
if recipient.startswith("odrzuc@"):
raise AcmeError("recipient rejected by provider")
return Message(id=f"msg_{abs(hash((recipient, content))) % 10**8:08d}", recipient=recipient, content=content)
class AcmeClient:
"""Klient 2.x. Zasoby zwalniane automatycznie - brak metody close()."""
def __init__(self, api_key: str, base_url: str, timeout: float = 10.0) -> None:
if not api_key:
raise AcmeError("api_key is required")
self.api_key = api_key
self.base_url = base_url
self.timeout = timeout
self.messages = _Messages(self)
def __enter__(self) -> "AcmeClient":
return self
def __exit__(self, *exc_info: object) -> None:
return None
__all__ = ["AcmeClient", "AcmeError", "Message", "__version__"]
@@ -0,0 +1,5 @@
"""Hierarchia błędów acme-sdk - niezmieniona między 1.x a 2.x."""
class AcmeError(Exception):
"""Błąd zwrócony przez dostawcę."""
@@ -0,0 +1,15 @@
from acme_app.notifier import send_reminder, send_welcome
def test_send_welcome_zwraca_identyfikator():
message_id = send_welcome("jan@example.com", api_key="k-1", endpoint="https://acme.internal")
assert message_id.startswith("msg_")
def test_send_reminder_zwraca_identyfikator():
message_id = send_reminder("jan@example.com", api_key="k-1", endpoint="https://acme.internal", body="Przypomnienie")
assert message_id.startswith("msg_")
def test_send_reminder_zwraca_none_przy_bledzie_dostawcy():
assert send_reminder("odrzuc@example.com", api_key="k-1", endpoint="https://acme.internal", body="x") is None
+62
View File
@@ -0,0 +1,62 @@
# Backendy LLM uruchamiane kontenerowo. Sterowane profilami, żeby jeden plik
# obsłużył kilka wariantów i żeby `docker compose up` bez profilu nic nie robił.
#
# docker compose --profile vllm-gpu up -d # Linux + NVIDIA
# docker compose --profile ollama up -d # awaryjnie, obie platformy
#
# Zwykle nie wywołujesz tego wprost - robi to `task llm:up`.
name: agentic-codemod-llm
services:
vllm:
profiles: ["vllm-gpu"]
image: vllm/vllm-openai:${VLLM_IMAGE_TAG:-latest}
command: >
--model ${LLM_SERVE_MODEL:-Qwen/Qwen3-4B-Instruct-2507}
--max-model-len=16384
--enable-auto-tool-choice
--tool-call-parser=hermes
--gpu-memory-utilization=${VLLM_GPU_UTIL:-0.90}
ports:
- "${LLM_PORT:-8000}:8000"
volumes:
# Cache modeli poza kontenerem - restart nie oznacza ponownego pobierania.
- ${HF_CACHE:-${HOME}/.cache/huggingface}:/root/.cache/huggingface
environment:
HUGGING_FACE_HUB_TOKEN: ${HUGGING_FACE_HUB_TOKEN:-}
# vLLM używa pamięci dzielonej do komunikacji między workerami
ipc: host
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request;urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 15s
timeout: 5s
retries: 40
start_period: 300s
restart: unless-stopped
ollama:
profiles: ["ollama"]
image: ollama/ollama:${OLLAMA_IMAGE_TAG:-latest}
ports:
- "${OLLAMA_PORT:-11434}:11434"
volumes:
- ollama-models:/root/.ollama
healthcheck:
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
restart: unless-stopped
volumes:
ollama-models:
+303
View File
@@ -0,0 +1,303 @@
"""Atrapa serwera zgodnego z OpenAI - do testowania ścieżki agentowej bez modelu.
Po co to jest
------------
Tryb `--offline` sprawdza `RuleBrain`, ale nie dotyka tego, co w tym projekcie jest
najbardziej kruche: budowy agentów z definicji APM, wywoływania narzędzi przez model
i parsowania strukturalnych wyjść. Ta atrapa domyka lukę - pozwala przepuścić
`LlmBrain` przez pełny przebieg na każdym MR, na Linuksie i na macOS, bez GPU
i bez pobierania modelu.
Czym to NIE jest
----------------
To nie jest symulator modelu. Scenariusze zestrojone z fixture'em `acme-app`:
atrapa odpowiada z góry ustalonymi wywołaniami narzędzi i strukturami. Sprawdza,
czy instalacja hydrauliczna trzyma wodę - nie czy model jest mądry.
Routing odpowiedzi po nazwie agenta z komunikatu systemowego ("Your name is: coder."),
którą wstawia agno przy `add_name_to_context=True`.
Uruchomienie:
python3 llm/mock_server.py --port 8077
"""
from __future__ import annotations
import argparse
import json
import re
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
MODEL_ID = "mock/agentic-codemod"
_NAME_RE = re.compile(r"Your name is:\s*([a-z0-9_-]+)", re.IGNORECASE)
# --------------------------------------------------------------------------- scenariusze
REPO_PROFILE = {
"build_system": "python-pip",
"dependency_files": ["pyproject.toml"],
"declared_version": "==1.4.2",
"module_name": "acme",
"usage_files": ["src/acme_app/notifier.py"],
"usage_symbols": ["Client", "send", "close"],
"verify_command": "python -m pytest -q",
"blast_radius": "low",
"gaps": [],
}
CHANGE_PLAN = {
"summary": "Migracja acme-sdk 1.4.2 -> 2.1.0: zmiana klasy klienta, wysyłki i odczytu odpowiedzi.",
"edits": [
{
"order": 1,
"path": "src/acme_app/notifier.py",
"intent": "Client -> AcmeClient, send() -> messages.create(), usunięcie close(), result['id'] -> result.id",
"rationale": "Jedyny plik używający SDK; zmiany opisane w notatce migracyjnej 2.x.",
"acceptance_criteria": "pytest kończy się kodem 0, brak wystąpień client.send( i .close()",
"requires_human": False,
"blocked_reason": None,
}
],
"out_of_scope": ["pyproject.toml (wersję ustawia pipeline)", "stubs/acme (atrapa dostawcy)"],
"risks": ["Brak testów integracyjnych z realnym dostawcą."],
}
REVIEW_VERDICT = {
"verdict": "approve",
"summary": "Diff ograniczony do jednego pliku i zgodny z planem; testy nietknięte.",
"findings": [
{
"severity": "info",
"category": "scope",
"path": "src/acme_app/notifier.py",
"message": "Zmiany wyłącznie w warstwie integracji z SDK.",
}
],
}
MERGE_REQUEST = {
"title": "build(deps): acme-sdk 1.4.2 -> 2.1.0 wraz z migracją API",
"description": (
"## Co i dlaczego\n\n"
"Podniesienie acme-sdk do 2.1.0 i dostosowanie wywołań do API 2.x.\n\n"
"## Zakres zmian\n\n- `pyproject.toml` - deklaracja wersji\n"
"- `src/acme_app/notifier.py` - migracja klienta i wysyłki\n\n"
"## Weryfikacja\n\n`pytest -q` -> rc=0\n\n"
"## Ryzyko i ograniczenia\n\n"
"Przebieg wykonany na atrapie modelu - opis służy weryfikacji pipeline'u, nie ocenie zmiany.\n"
),
}
# Fragmenty zestrojone z examples/fixtures/acme-app - atrapa "wie", co zastąpić.
_WELCOME_OLD = (
" client = Client(api_key=api_key, endpoint=endpoint)\n"
" result = client.send(to=email, body=WELCOME_BODY)\n"
" client.close()\n"
' return result["id"]'
)
_WELCOME_NEW = (
" client = AcmeClient(api_key=api_key, base_url=endpoint)\n"
" result = client.messages.create(recipient=email, content=WELCOME_BODY)\n"
" return result.id"
)
_REMINDER_OLD = (
" client = Client(api_key=api_key, endpoint=endpoint)\n"
" try:\n"
" result = client.send(to=email, body=body)\n"
" except AcmeError:\n"
" return None\n"
" client.close()\n"
' return result["id"]'
)
_REMINDER_NEW = (
" client = AcmeClient(api_key=api_key, base_url=endpoint)\n"
" try:\n"
" result = client.messages.create(recipient=email, content=body)\n"
" except AcmeError:\n"
" return None\n"
" return result.id"
)
CODER_SCRIPT: list[dict[str, Any]] = [
{"tool": "read_file", "args": {"path": "src/acme_app/notifier.py"}},
{
"tool": "replace_in_file",
"args": {
"path": "src/acme_app/notifier.py",
"old_text": "from acme import Client",
"new_text": "from acme import AcmeClient",
},
},
{
"tool": "replace_in_file",
"args": {"path": "src/acme_app/notifier.py", "old_text": _WELCOME_OLD, "new_text": _WELCOME_NEW},
},
{
"tool": "replace_in_file",
"args": {"path": "src/acme_app/notifier.py", "old_text": _REMINDER_OLD, "new_text": _REMINDER_NEW},
},
{"tool": "run_verification", "args": {}},
{
"content": (
"Plan wykonany. Zmieniony plik: src/acme_app/notifier.py "
"(import, konstruktor klienta, wysyłka, odczyt identyfikatora). "
"Weryfikacja zakończona powodzeniem."
)
},
]
def _agent_name(messages: list[dict[str, Any]]) -> str:
for message in messages:
content = message.get("content")
if isinstance(content, str):
match = _NAME_RE.search(content)
if match:
return match.group(1).lower()
return "unknown"
def _completed_tool_steps(messages: list[dict[str, Any]]) -> int:
return sum(1 for m in messages if m.get("role") == "assistant" and m.get("tool_calls"))
def _tool_call_message(step: dict[str, Any]) -> dict[str, Any]:
return {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": f"call_{uuid.uuid4().hex[:12]}",
"type": "function",
"function": {"name": step["tool"], "arguments": json.dumps(step["args"], ensure_ascii=False)},
}
],
}
def build_message(messages: list[dict[str, Any]]) -> tuple[dict[str, Any], str]:
"""Zwraca (wiadomość asystenta, finish_reason) dla danej rozmowy."""
name = _agent_name(messages)
if name == "coder":
index = _completed_tool_steps(messages)
if index >= len(CODER_SCRIPT):
return {"role": "assistant", "content": "Zakończono."}, "stop"
step = CODER_SCRIPT[index]
if "tool" in step:
return _tool_call_message(step), "tool_calls"
return {"role": "assistant", "content": step["content"]}, "stop"
payloads = {
"scout": REPO_PROFILE,
"planner": CHANGE_PLAN,
"reviewer": REVIEW_VERDICT,
"scribe": MERGE_REQUEST,
}
if name in payloads:
return {"role": "assistant", "content": json.dumps(payloads[name], ensure_ascii=False)}, "stop"
if name == "unknown" and not any(m.get("role") == "system" for m in messages):
# zwykłe zapytanie diagnostyczne (task llm:smoke), a nie agent z pipeline'u
return {"role": "assistant", "content": "dziala (atrapa LLM)"}, "stop"
return {
"role": "assistant",
"content": f"Atrapa LLM nie ma scenariusza dla agenta '{name}'. Uzupełnij llm/mock_server.py.",
}, "stop"
class MockHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "agentic-codemod-mock/1.0"
def log_message(self, fmt: str, *args: Any) -> None: # cichy log, chyba że --verbose
if getattr(self.server, "verbose", False):
super().log_message(fmt, *args)
# ------------------------------------------------------------------ util
def _send(self, payload: dict[str, Any], status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
# ------------------------------------------------------------------ HTTP
def do_GET(self) -> None:
if self.path.rstrip("/") in ("/healthz", "/v1/models", "/models"):
if "models" in self.path:
self._send({"object": "list", "data": [{"id": MODEL_ID, "object": "model", "owned_by": "mock"}]})
else:
self._send({"status": "ok", "model": MODEL_ID})
return
self._send({"error": {"message": f"nieobsługiwana ścieżka {self.path}"}}, status=404)
def do_POST(self) -> None:
if not self.path.rstrip("/").endswith("/chat/completions"):
self._send({"error": {"message": f"nieobsługiwana ścieżka {self.path}"}}, status=404)
return
length = int(self.headers.get("Content-Length", "0"))
request = json.loads(self.rfile.read(length) or b"{}")
if request.get("stream"):
# agno w tym pipelinie nie streamuje; jawny błąd jest lepszy niż ciche milczenie
self._send({"error": {"message": "atrapa nie obsługuje stream=true"}}, status=400)
return
messages = request.get("messages") or []
message, finish_reason = build_message(messages)
# Jedna zwięzła linia na żądanie - dzięki temu `task llm:logs` pokazuje przebieg
# rozmowy z agentami także na atrapie, a nie tylko przy realnym backendzie.
tool = ""
if message.get("tool_calls"):
tool = " -> " + ", ".join(c["function"]["name"] for c in message["tool_calls"])
print(
f"[mock] agent={_agent_name(messages):<9} wiadomości={len(messages):<3} finish={finish_reason}{tool}",
flush=True,
)
self._send(
{
"id": f"chatcmpl-{uuid.uuid4().hex[:16]}",
"object": "chat.completion",
"created": int(time.time()),
"model": request.get("model") or MODEL_ID,
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason, "logprobs": None}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
)
def serve(host: str = "127.0.0.1", port: int = 8077, verbose: bool = False) -> ThreadingHTTPServer:
httpd = ThreadingHTTPServer((host, port), MockHandler)
httpd.verbose = verbose # type: ignore[attr-defined]
httpd.daemon_threads = True
return httpd
def main() -> None:
parser = argparse.ArgumentParser(description="Atrapa serwera OpenAI dla testów ścieżki agentowej")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8077)
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
httpd = serve(args.host, args.port, args.verbose)
print(f"atrapa LLM: http://{args.host}:{args.port}/v1 (model: {MODEL_ID})", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.shutdown()
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
# Jedyne źródło prawdy o backendach LLM i modelach per rola.
#
# Kontrakt jest jeden: endpoint zgodny z API OpenAI pod CODEMOD_LLM_BASE_URL.
# Backendy różnią się tym, jak ten endpoint powstaje - nie tym, co widzi silnik.
#
# `task llm:env` renderuje z tego pliku zmienne środowiskowe,
# `task llm:up` uruchamia wskazany backend.
default_backend: auto # auto = wykrycie platformy (macOS -> vllm-metal, Linux+NVIDIA -> vllm-gpu)
backends:
vllm-gpu:
description: vLLM w kontenerze, Linux z GPU NVIDIA. Ścieżka najbliższa produkcji na OpenShifcie.
platforms: [linux]
requires: [docker, nvidia-gpu]
provider: vllm
base_url: http://localhost:8000/v1
api_key: not-required
serve:
model: Qwen/Qwen3-4B-Instruct-2507
# --enable-auto-tool-choice + parser są konieczne: agenci wołają narzędzia,
# bez tego vLLM zwróci opis wywołania w treści zamiast tool_calls.
args:
- --max-model-len=16384
- --enable-auto-tool-choice
- --tool-call-parser=hermes
- --gpu-memory-utilization=0.90
models:
planner: Qwen/Qwen3-4B-Instruct-2507
coder: Qwen/Qwen3-4B-Instruct-2507
reviewer: Qwen/Qwen3-4B-Instruct-2507
scribe: Qwen/Qwen3-4B-Instruct-2507
vllm-metal:
description: >
vLLM natywnie na Apple Silicon przez plugin vllm-project/vllm-metal (backend MLX).
Docker na macOS nie ma dostępu do Metala, więc kontener nie jest tu opcją.
platforms: [darwin]
requires: [python3.12-arm64, xcode-cli]
provider: vllm
base_url: http://localhost:8000/v1
api_key: not-required
serve:
model: mlx-community/Qwen3-4B-Instruct-2507-4bit
args:
- --max-model-len=16384
- --enable-auto-tool-choice
- --tool-call-parser=hermes
models:
planner: mlx-community/Qwen3-4B-Instruct-2507-4bit
coder: mlx-community/Qwen3-4B-Instruct-2507-4bit
reviewer: mlx-community/Qwen3-4B-Instruct-2507-4bit
scribe: mlx-community/Qwen3-4B-Instruct-2507-4bit
ollama:
description: >
Wariant awaryjny na obie platformy. Nie jest vLLM, ale wystawia /v1 zgodne z OpenAI
i wstaje jedną komendą. Na macOS instaluj natywnie (Metal) - w Dockerze liczy na CPU.
platforms: [linux, darwin]
requires: [ollama]
provider: openai_like
base_url: http://localhost:11434/v1
api_key: ollama
serve:
model: qwen3:4b-instruct
args: []
models:
planner: qwen3:4b-instruct
coder: qwen3:4b-instruct
reviewer: qwen3:4b-instruct
scribe: qwen3:4b-instruct
mock:
description: >
Atrapa z llm/mock_server.py. Zero pobierania, zero GPU, deterministyczne odpowiedzi
zestrojone z fixture'em acme-app. Do testów ścieżki agentowej w CI, nie do pracy.
platforms: [linux, darwin, windows]
requires: []
provider: openai_like
base_url: http://127.0.0.1:8077/v1
api_key: mock
serve:
model: mock/agentic-codemod
args: []
models:
planner: mock/agentic-codemod
coder: mock/agentic-codemod
reviewer: mock/agentic-codemod
scribe: mock/agentic-codemod
# Rekomendacje przy dobieraniu większych modeli (rola -> czego wymaga):
# coder - najważniejszy: stabilne tool-calling i trzymanie się formatu edycji
# planner - rozumowanie o zakresie zmiany; zysk z większego modelu jest tu wyraźny
# reviewer - może być ten sam co planner; osobny model bywa lepszy (inny punkt widzenia)
# scribe - najmniejszy wystarczy, to redakcja tekstu
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Odczyt llm/models.yml: wykrycie backendu i renderowanie zmiennych środowiskowych.
Jedno miejsce decyduje, jaki backend jest właściwy dla platformy i jakie zmienne
dostaje silnik - Taskfile tylko o to pyta, nie duplikuje wiedzy.
Użycie:
backend.py detect # nazwa backendu dla tej maszyny
backend.py env [--backend NAME] # linie `export ...` do eval
backend.py get serve.model [--backend NAME]
backend.py list
"""
from __future__ import annotations
import argparse
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
import yaml
MODELS_FILE = Path(__file__).resolve().parent.parent / "models.yml"
def load() -> dict[str, Any]:
return yaml.safe_load(MODELS_FILE.read_text(encoding="utf-8")) or {}
def _has_nvidia_gpu() -> bool:
if not shutil.which("nvidia-smi"):
return False
try:
return subprocess.run(["nvidia-smi", "-L"], capture_output=True, timeout=10).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _has_docker() -> bool:
if not shutil.which("docker"):
return False
try:
return subprocess.run(["docker", "info"], capture_output=True, timeout=20).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def detect() -> str:
"""Kolejność jest świadoma: najpierw akceleracja sprzętowa, potem cokolwiek, co działa."""
system = platform.system().lower()
if system == "darwin" and platform.machine() == "arm64":
return "vllm-metal"
if system == "linux" and _has_nvidia_gpu() and _has_docker():
return "vllm-gpu"
if shutil.which("ollama") or _has_docker():
return "ollama"
# Brak GPU, Metala i Ollamy: realnego modelu i tak nie ma sensu tu uruchamiać.
return "mock"
def resolve(name: str | None) -> tuple[str, dict[str, Any]]:
data = load()
backends = data.get("backends") or {}
if not name or name == "auto":
name = detect()
if name not in backends:
raise SystemExit(f"Nieznany backend '{name}'. Dostępne: {', '.join(sorted(backends))}")
return name, backends[name]
def _dotted(config: dict[str, Any], path: str) -> Any:
value: Any = config
for part in path.split("."):
if not isinstance(value, dict) or part not in value:
raise SystemExit(f"Brak klucza '{path}' w konfiguracji backendu")
value = value[part]
return value
def cmd_env(args: argparse.Namespace) -> None:
name, config = resolve(args.backend)
models = config.get("models") or {}
lines = [
f"export CODEMOD_LLM_BACKEND={name}",
f"export CODEMOD_MODEL_PROVIDER={config.get('provider', 'openai_like')}",
f"export CODEMOD_LLM_BASE_URL={config['base_url']}",
f"export CODEMOD_LLM_API_KEY={config.get('api_key', 'not-required')}",
]
lines += [f"export CODEMOD_MODEL_{role.upper()}={model}" for role, model in sorted(models.items())]
print("\n".join(lines))
def cmd_detect(args: argparse.Namespace) -> None:
requested = (args.requested or "auto").strip()
print(detect() if requested in ("", "auto") else requested)
def cmd_get(args: argparse.Namespace) -> None:
_, config = resolve(args.backend)
value = _dotted(config, args.key)
print(" ".join(str(v) for v in value) if isinstance(value, list) else value)
def cmd_list(_: argparse.Namespace) -> None:
data = load()
current = detect()
for name, config in (data.get("backends") or {}).items():
marker = "*" if name == current else " "
platforms = ", ".join(config.get("platforms", []))
print(f" {marker} {name:<12} {config['base_url']:<32} [{platforms}]")
print("\n * = wykryty jako właściwy dla tej maszyny")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
det = sub.add_parser("detect")
det.add_argument("--requested", default="auto", help="jawnie wskazany backend; 'auto' = wykrycie")
det.set_defaults(func=cmd_detect)
sub.add_parser("list").set_defaults(func=cmd_list)
env = sub.add_parser("env")
env.add_argument("--backend")
env.set_defaults(func=cmd_env)
get = sub.add_parser("get")
get.add_argument("key")
get.add_argument("--backend")
get.set_defaults(func=cmd_get)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
sys.exit(main())
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Uruchamia komendę w tle, odczepioną od bieżącej sesji terminala.
Powód istnienia: `nohup ... &` wewnątrz zadania go-task nie wystarcza - interpreter
zadania kończy się razem z krokiem i zabija potomka. `start_new_session=True` robi
to, co `setsid` na Linuksie, a działa też na macOS, gdzie `setsid` nie istnieje.
Użycie:
daemonize.py --pidfile .runs/llm/mock.pid --log .runs/llm/mock.log -- python3 llm/mock_server.py
"""
from __future__ import annotations
import argparse
import os
import signal
import subprocess
import sys
from pathlib import Path
def already_running(pidfile: Path) -> int | None:
if not pidfile.exists():
return None
try:
pid = int(pidfile.read_text().strip())
os.kill(pid, 0)
except (ValueError, ProcessLookupError, PermissionError, OSError):
return None
return pid
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--pidfile", required=True)
parser.add_argument("--log", required=True)
parser.add_argument("--stop", action="store_true", help="zatrzymaj proces z pidfile zamiast startować")
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
pidfile = Path(args.pidfile)
pidfile.parent.mkdir(parents=True, exist_ok=True)
if args.stop:
pid = already_running(pidfile)
if pid is None:
print(f"nic nie działa pod {pidfile}")
pidfile.unlink(missing_ok=True)
return 0
os.kill(pid, signal.SIGTERM)
pidfile.unlink(missing_ok=True)
print(f"zatrzymano pid {pid}")
return 0
command = [arg for arg in args.command if arg != "--"]
if not command:
parser.error("podaj komendę do uruchomienia po --")
running = already_running(pidfile)
if running is not None:
print(f"już działa (pid {running})")
return 0
log = Path(args.log)
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("ab") as handle:
process = subprocess.Popen(
command,
stdout=handle,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True, # odpowiednik setsid, przenośny między Linuksem a macOS
)
pidfile.write_text(str(process.pid))
print(f"uruchomiono pid {process.pid} (log: {log})")
return 0
if __name__ == "__main__":
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Podgląd logów backendu LLM - jedno wejście niezależnie od tego, czy backend
# działa w kontenerze (vllm-gpu) czy jako proces z plikiem logu (vllm-metal, ollama, mock).
#
# Użycie:
# logs.sh --backend vllm-gpu [--tail 100] [--follow|--no-follow] [--grep WZORZEC] [--save PLIK]
#
# Domyślnie śledzi na bieżąco (jak `tail -f`). --no-follow robi jednorazowy zrzut,
# co jest tym, czego się chce przy wklejaniu fragmentu do zgłoszenia.
set -euo pipefail
# Ctrl-C przy śledzeniu logów to normalne zakończenie, nie awaria - bez tego
# go-task raportowałby "Failed to run task" za każdym razem, gdy ktoś przerwie podgląd.
trap 'exit 0' INT TERM
BACKEND=""
RUN_DIR=".runs/llm"
TAIL="100"
FOLLOW="true"
PATTERN=""
SAVE=""
COMPOSE_FILE="llm/docker-compose.yml"
usage() { sed -n '2,12p' "$0" >&2; exit "${1:-1}"; }
while [ $# -gt 0 ]; do
case "$1" in
--backend) BACKEND="${2:?}"; shift 2 ;;
--run-dir) RUN_DIR="${2:?}"; shift 2 ;;
--tail) TAIL="${2:?}"; shift 2 ;;
--grep) PATTERN="${2:?}"; shift 2 ;;
--save) SAVE="${2:?}"; shift 2 ;;
--follow) FOLLOW="true"; shift ;;
--no-follow) FOLLOW="false"; shift ;;
-h|--help) usage 0 ;;
*) echo "nieznany argument: $1" >&2; usage ;;
esac
done
[ -n "$BACKEND" ] || { echo "podaj --backend" >&2; exit 1; }
# Zapis do pliku bez sensu w trybie śledzenia - zrzut ma się skończyć.
[ -n "$SAVE" ] && FOLLOW="false"
log_file_for() {
case "$1" in
vllm-metal) echo "$RUN_DIR/vllm-metal.log" ;;
ollama) echo "$RUN_DIR/ollama.log" ;;
mock) echo "$RUN_DIR/mock.log" ;;
*) echo "" ;;
esac
}
# Strumień logów na stdout, zależnie od backendu.
emit() {
if [ "$BACKEND" = "vllm-gpu" ]; then
command -v docker >/dev/null 2>&1 || { echo "brak dockera - a backend vllm-gpu działa w kontenerze" >&2; exit 1; }
if [ "$FOLLOW" = "true" ]; then
docker compose -f "$COMPOSE_FILE" --profile vllm-gpu logs --follow --tail "$TAIL"
else
docker compose -f "$COMPOSE_FILE" --profile vllm-gpu logs --tail "$TAIL"
fi
return
fi
local file
file="$(log_file_for "$BACKEND")"
if [ -z "$file" ]; then
echo "backend '$BACKEND' nie ma znanego źródła logów" >&2
exit 1
fi
if [ ! -f "$file" ]; then
echo "brak pliku logu: $file" >&2
echo "backend prawdopodobnie nie był uruchamiany - zacznij od: task llm:up" >&2
exit 1
fi
if [ "$FOLLOW" = "true" ]; then
# --follow=name odtwarza plik po rotacji/nadpisaniu, np. przy restarcie backendu
tail -n "$TAIL" -F "$file"
else
tail -n "$TAIL" "$file"
fi
}
filter() {
if [ -n "$PATTERN" ]; then
# --line-buffered: przy śledzeniu linie mają się pojawiać od razu, a nie po zapełnieniu bufora
grep --line-buffered -Ei "$PATTERN" || true
else
cat
fi
}
if [ -n "$SAVE" ]; then
mkdir -p "$(dirname "$SAVE")"
emit | filter > "$SAVE"
echo "zapisano $(wc -l < "$SAVE" | tr -d ' ') linii do $SAVE"
elif [ "$FOLLOW" = "true" ]; then
emit | filter
else
# Jednorazowy zrzut: pusty wynik przy filtrze jest dwuznaczny (brak dopasowań
# czy brak logów?), więc mówimy to wprost zamiast milczeć.
output="$(emit | filter)"
if [ -z "$output" ] && [ -n "$PATTERN" ]; then
echo "brak linii pasujących do '$PATTERN' w ostatnich $TAIL liniach logu" >&2
else
printf '%s\n' "$output"
fi
fi
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# vLLM na Apple Silicon przez plugin vllm-project/vllm-metal (backend MLX).
#
# Docker na macOS nie ma dostępu do Metala, więc to jedyna ścieżka z akceleracją.
# Plugin instaluje się do własnego venva (~/.venv-vllm-metal) i wymaga natywnego
# arm64 Pythona 3.12 oraz Xcode Command Line Tools.
#
# Użycie: vllm-metal.sh up <model> <port> <log> <pid> | down <pid> | install
set -euo pipefail
VENV="${VLLM_METAL_VENV:-$HOME/.venv-vllm-metal}"
INSTALLER_URL="https://raw.githubusercontent.com/vllm-project/vllm-metal/main/install.sh"
fail() { echo "błąd: $*" >&2; exit 1; }
check_platform() {
[ "$(uname -s)" = "Darwin" ] || fail "ten backend działa wyłącznie na macOS (wykryto $(uname -s))"
[ "$(uname -m)" = "arm64" ] || fail "wymagany Apple Silicon (wykryto $(uname -m))"
xcode-select -p >/dev/null 2>&1 || fail "brak Xcode Command Line Tools - uruchom: xcode-select --install"
}
install_plugin() {
check_platform
if [ -x "$VENV/bin/vllm" ]; then
echo "vllm-metal już zainstalowany w $VENV"
return 0
fi
echo "instaluję vllm-metal do $VENV (to potrwa kilka minut)..."
curl -fsSL "$INSTALLER_URL" | bash
[ -x "$VENV/bin/vllm" ] || fail "instalacja nie powiodła się - sprawdź https://github.com/vllm-project/vllm-metal"
}
case "${1:-}" in
install)
install_plugin
;;
up)
model="${2:?podaj model}"; port="${3:-8000}"; log="${4:-.runs/llm/vllm-metal.log}"; pidfile="${5:-.runs/llm/vllm-metal.pid}"
install_plugin
mkdir -p "$(dirname "$log")" "$(dirname "$pidfile")"
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
echo "vllm-metal już działa (pid $(cat "$pidfile"))"; exit 0
fi
echo "startuję vllm serve $model na porcie $port (log: $log)"
python3 "$(dirname "$0")/daemonize.py" --pidfile "$pidfile" --log "$log" -- \
"$VENV/bin/vllm" serve "$model" \
--port "$port" \
--max-model-len 16384 \
--enable-auto-tool-choice \
--tool-call-parser hermes
echo "pierwsze uruchomienie pobiera model - to może potrwać (log: $log)"
;;
down)
pidfile="${2:-.runs/llm/vllm-metal.pid}"
python3 "$(dirname "$0")/daemonize.py" --pidfile "$pidfile" --log /dev/null --stop
;;
*)
fail "użycie: $0 {install|up <model> <port> <log> <pid>|down <pid>}"
;;
esac
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Czeka, aż endpoint OpenAI odpowie na /v1/models. Pobranie modelu potrafi trwać,
# więc domyślny limit jest hojny, a komunikat mówi, gdzie zajrzeć.
set -euo pipefail
url="${1:?podaj base_url, np. http://localhost:8000/v1}"
timeout="${2:-900}"
log="${3:-}"
deadline=$(( $(date +%s) + timeout ))
printf 'czekam na %s/models ' "$url"
while [ "$(date +%s)" -lt "$deadline" ]; do
if curl -fsS --max-time 5 "$url/models" >/dev/null 2>&1; then
printf '\nendpoint gotowy: %s\n' "$url"
exit 0
fi
printf '.'
sleep 3
done
printf '\n'
echo "endpoint nie odpowiedział w ciągu ${timeout}s" >&2
[ -n "$log" ] && [ -f "$log" ] && { echo "--- ostatnie 30 linii $log:" >&2; tail -30 "$log" >&2; }
exit 1
+44
View File
@@ -0,0 +1,44 @@
[project]
name = "agentic-codemod"
version = "0.1.0"
description = "Referencyjna sieć agentowa (agno) do modyfikacji kodu, zasilana kontekstem z pakietów APM i uruchamiana w GitLab CI"
requires-python = ">=3.10"
dependencies = [
"agno>=3.0.0,<4",
"openai>=1.60", # klient OpenAI-compatible (vLLM)
"pydantic>=2.7",
"pyyaml>=6.0",
"typer>=0.12",
"httpx>=0.27",
"rich>=13.7",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.6", "mypy>=1.11"]
ollama = ["ollama>=0.3"]
[project.scripts]
agentic-codemod = "agentic_codemod.cli:app"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["llm: wymaga działającego endpointu LLM (pomijany domyślnie)"]
filterwarnings = ["ignore::DeprecationWarning"]
[tool.ruff]
line-length = 120
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
# typer wymaga wywołania Option() w domyślnej wartości argumentu - to jego API, nie błąd
"src/agentic_codemod/cli.py" = ["B008"]
+12
View File
@@ -0,0 +1,12 @@
"""Referencyjna sieć agentowa do modyfikacji kodu.
Warstwy:
apm/ - kontekst agentów (skille, prompty, instrukcje, definicje agentów) z pakietów APM
adapters/ - wiedza o ekosystemach budowania (pip, maven, npm)
tools/ - sandboxowane narzędzia agno, jedyny sposób kontaktu agenta z repozytorium
workflow/ - topologia sieci agentowej jako agno Workflow
observability/ - ślad audytowy przebiegu
integrations/ - GitLab (MR, komentarze)
"""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import app
if __name__ == "__main__":
app()
+43
View File
@@ -0,0 +1,43 @@
"""Rejestr adapterów ekosystemów."""
from __future__ import annotations
from pathlib import Path
from .base import EcosystemAdapter
from .maven import MavenAdapter
from .node_npm import NodeNpmAdapter
from .python_pip import PythonPipAdapter
REGISTRY: tuple[type[EcosystemAdapter], ...] = (PythonPipAdapter, MavenAdapter, NodeNpmAdapter)
class EcosystemNotDetected(RuntimeError):
pass
def detect_adapter(root: Path | str) -> EcosystemAdapter:
"""Wybiera adapter na podstawie plików markerowych. Brak dopasowania = twardy błąd.
Świadomie nie ma tu fallbacku "spróbuj czegokolwiek" - pipeline modyfikujący kod
w banku musi wiedzieć, czym jest repozytorium, zanim cokolwiek zmieni.
"""
root = Path(root)
for adapter_cls in REGISTRY:
if adapter_cls.detect(root):
return adapter_cls(root)
raise EcosystemNotDetected(
f"Nie rozpoznano ekosystemu w {root}. Obsługiwane markery: "
+ ", ".join(sorted({m for a in REGISTRY for m in a.marker_files}))
)
__all__ = [
"EcosystemAdapter",
"EcosystemNotDetected",
"MavenAdapter",
"NodeNpmAdapter",
"PythonPipAdapter",
"REGISTRY",
"detect_adapter",
]
+57
View File
@@ -0,0 +1,57 @@
"""Adaptery ekosystemów budowania.
Sieć agentowa jest agnostyczna językowo. Wiedza "gdzie stoi wersja zależności i jak
uruchomić testy" jest deterministyczna i nie powinna być zgadywana przez model -
mieszka tutaj. Model dostaje wynik adaptera jako fakt.
"""
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from pathlib import Path
class EcosystemAdapter(ABC):
name: str = "base"
#: pliki, których obecność świadczy o ekosystemie (kolejność = priorytet)
marker_files: tuple[str, ...] = ()
def __init__(self, root: Path) -> None:
self.root = Path(root).resolve()
# --------------------------------------------------------------- detekcja
@classmethod
def detect(cls, root: Path) -> bool:
return any((Path(root) / marker).exists() for marker in cls.marker_files)
# ------------------------------------------------------------- zależności
@abstractmethod
def dependency_files(self) -> list[Path]:
"""Pliki deklarujące zależności, które realnie istnieją w repozytorium."""
@abstractmethod
def read_declared_version(self, package: str) -> str | None:
"""Zwraca deklarowaną wersję pakietu tak, jak jest zapisana (bez normalizacji)."""
@abstractmethod
def set_declared_version(self, package: str, version: str) -> list[Path]:
"""Ustawia wersję pakietu we wszystkich plikach zależności. Zwraca zmienione pliki."""
def module_name(self, package: str) -> str:
"""Heurystyka: nazwa pakietu dystrybucyjnego -> nazwa modułu importu."""
return package.replace("-", "_")
# ------------------------------------------------------------ weryfikacja
@abstractmethod
def verify_command(self) -> list[str]:
"""Komenda budująca i testująca repozytorium."""
def install_command(self) -> list[str] | None:
"""Opcjonalna komenda przygotowania środowiska (offline w CI)."""
return None
def _replace_in_text(text: str, pattern: re.Pattern[str], replacement) -> tuple[str, int]:
new_text, count = pattern.subn(replacement, text)
return new_text, count
+62
View File
@@ -0,0 +1,62 @@
"""Ekosystem Java/Maven - wersje trzymane w properties lub w <dependency>.
Status: adapter referencyjny (bez fixture w tym repozytorium). Pokazuje, jak dołożyć
kolejny ekosystem bez dotykania warstwy agentowej.
"""
from __future__ import annotations
import re
from pathlib import Path
from .base import EcosystemAdapter
class MavenAdapter(EcosystemAdapter):
name = "java-maven"
marker_files = ("pom.xml",)
def dependency_files(self) -> list[Path]:
return [p for p in [self.root / "pom.xml", *sorted(self.root.glob("*/pom.xml"))] if p.exists()]
@staticmethod
def _coords(package: str) -> tuple[str, str]:
if ":" not in package:
raise ValueError("Dla Mavena podaj koordynaty w formacie groupId:artifactId")
group, artifact = package.split(":", 1)
return group, artifact
def _dependency_pattern(self, package: str) -> re.Pattern[str]:
group, artifact = self._coords(package)
return re.compile(
rf"(<groupId>\s*{re.escape(group)}\s*</groupId>\s*"
rf"<artifactId>\s*{re.escape(artifact)}\s*</artifactId>\s*"
rf"<version>)(?P<version>[^<]+)(</version>)",
re.DOTALL,
)
def read_declared_version(self, package: str) -> str | None:
pattern = self._dependency_pattern(package)
for path in self.dependency_files():
match = pattern.search(path.read_text(encoding="utf-8"))
if match:
return match.group("version").strip()
return None
def set_declared_version(self, package: str, version: str) -> list[Path]:
pattern = self._dependency_pattern(package)
changed: list[Path] = []
for path in self.dependency_files():
original = path.read_text(encoding="utf-8")
updated = pattern.sub(rf"\g<1>{version}\g<3>", original)
if updated != original:
path.write_text(updated, encoding="utf-8")
changed.append(path)
return changed
def module_name(self, package: str) -> str:
group, _ = self._coords(package)
return group
def verify_command(self) -> list[str]:
return ["mvn", "-B", "-o", "verify"]
+55
View File
@@ -0,0 +1,55 @@
"""Ekosystem Node/npm - wersje w package.json.
Status: adapter referencyjny (bez fixture w tym repozytorium).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from .base import EcosystemAdapter
_SECTIONS = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies")
class NodeNpmAdapter(EcosystemAdapter):
name = "node-npm"
marker_files = ("package.json",)
def dependency_files(self) -> list[Path]:
return [p for p in [self.root / "package.json"] if p.exists()]
def read_declared_version(self, package: str) -> str | None:
for path in self.dependency_files():
data = json.loads(path.read_text(encoding="utf-8"))
for section in _SECTIONS:
value = (data.get(section) or {}).get(package)
if value:
return str(value)
return None
def set_declared_version(self, package: str, version: str) -> list[Path]:
changed: list[Path] = []
for path in self.dependency_files():
original = path.read_text(encoding="utf-8")
data = json.loads(original)
updated = original
for section in _SECTIONS:
current = (data.get(section) or {}).get(package)
if not current:
continue
prefix = re.match(r"^[\^~]?", str(current)).group(0)
pattern = re.compile(rf'("{re.escape(package)}"\s*:\s*")[^"]+(")')
updated = pattern.sub(rf"\g<1>{prefix}{version}\g<2>", updated)
if updated != original:
path.write_text(updated, encoding="utf-8")
changed.append(path)
return changed
def module_name(self, package: str) -> str:
return package
def verify_command(self) -> list[str]:
return ["npm", "test", "--silent"]
@@ -0,0 +1,86 @@
"""Ekosystem Python: pyproject.toml, requirements*.txt, constraints.txt."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from .base import EcosystemAdapter
def _requirement_pattern(package: str) -> re.Pattern[str]:
"""Dopasowuje deklarację zależności: acme-sdk==1.4.2, acme_sdk >= 1.4, "acme-sdk~=1.4.2".
Operator jest WYMAGANY. Bez tego wzorzec łapie też prozę ("acme-sdk 1.x" w opisie
projektu) i pipeline modyfikuje tekst, którego nikt go nie prosił o zmianę -
dokładnie ten rodzaj cichego rozszerzenia zakresu, którego zakazują guardraile.
"""
name = re.escape(package).replace(r"\-", "[-_]")
return re.compile(
rf"(?P<name>\b{name}\b)(?P<space>\s*)(?P<op>==|>=|<=|~=|!=|>|<)(?P<space2>\s*)(?P<version>[0-9][^\s,\"'\]]*)",
re.IGNORECASE,
)
class PythonPipAdapter(EcosystemAdapter):
name = "python-pip"
marker_files = ("pyproject.toml", "requirements.txt", "setup.cfg", "setup.py")
_candidates = ("pyproject.toml", "requirements.txt", "requirements-dev.txt", "constraints.txt", "setup.cfg")
def dependency_files(self) -> list[Path]:
files = [self.root / name for name in self._candidates]
files.extend(sorted(self.root.glob("requirements/*.txt")))
return [f for f in files if f.exists()]
def read_declared_version(self, package: str) -> str | None:
pattern = _requirement_pattern(package)
for path in self.dependency_files():
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
match = pattern.search(line)
if match and match.group("version"):
return f"{match.group('op') or ''}{match.group('version')}"
return None
def set_declared_version(self, package: str, version: str) -> list[Path]:
pattern = _requirement_pattern(package)
changed: list[Path] = []
for path in self.dependency_files():
original = path.read_text(encoding="utf-8")
lines = original.splitlines(keepends=True)
updated: list[str] = []
touched = False
for line in lines:
if line.strip().startswith("#") or not pattern.search(line):
updated.append(line)
continue
def _sub(match: re.Match[str]) -> str:
# zachowujemy oryginalny operator i odstępy - diff ma pokazywać
# wyłącznie zmianę wersji, nie przeformatowanie linii
return (
f"{match.group('name')}{match.group('space')}{match.group('op')}"
f"{match.group('space2')}{version}"
)
new_line = pattern.sub(_sub, line)
touched = touched or new_line != line
updated.append(new_line)
if touched:
path.write_text("".join(updated), encoding="utf-8")
changed.append(path)
return changed
def verify_command(self) -> list[str]:
"""Weryfikacja tym samym interpreterem, który uruchamia pipeline.
Nie `python3` z PATH: przy uruchomieniu z venva (a tak działa Taskfile i obraz CI)
systemowy `python3` na macOS nie ma pytesta i weryfikacja byłaby czerwona
z powodu środowiska, a nie z powodu kodu. Repozytorium z własnym środowiskiem
budowania nadpisuje komendę przez `--verify-command`.
"""
return [sys.executable or "python3", "-m", "pytest", "-q"]
View File
+88
View File
@@ -0,0 +1,88 @@
"""Kompilacja prymitywów APM do obiektów agno.
To jest sedno rozwiązania: definicja agenta (rola, model, narzędzia, skille, schemat wyjścia)
jest wersjonowanym artefaktem APM, a nie kodem. Zmiana zachowania sieci agentowej
nie wymaga zmiany Pythona - wymaga podbicia wersji pakietu kontekstowego.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from agno.agent import Agent
from agno.skills import LocalSkills, Skills
from ..config import Settings
from ..llm import ModelFactory
from .loader import ApmContext
from .primitives import AgentPrimitive
class ToolResolutionError(RuntimeError):
pass
def _resolve_tools(names: list[str], registry: dict[str, Callable[..., Any]]) -> list[Callable[..., Any]]:
unknown = [n for n in names if n not in registry]
if unknown:
raise ToolResolutionError(
f"Definicja agenta odwołuje się do nieistniejących narzędzi: {', '.join(unknown)}. "
f"Dostępne: {', '.join(sorted(registry))}"
)
return [registry[n] for n in names]
def build_skills(ctx: ApmContext, names: list[str]) -> Skills | None:
"""Buduje natywny obiekt `Skills` agno z katalogów skilli dostarczonych przez APM.
agno waliduje katalogi względem specyfikacji Agent Skills - niepoprawny skill
wysadza przebieg na starcie, a nie w połowie modyfikacji kodu.
"""
if not names:
return None
paths = ctx.skill_paths(names)
return Skills(loaders=[LocalSkills(str(p)) for p in paths])
def build_agent(
definition: AgentPrimitive,
ctx: ApmContext,
settings: Settings,
model_factory: ModelFactory,
tool_registry: dict[str, Callable[..., Any]],
schema_registry: dict[str, type] | None = None,
) -> Agent:
schema_registry = schema_registry or {}
instructions: list[str] = []
instructions.extend(ctx.context_bodies(definition.context_names))
instructions.append(definition.body)
instructions.extend(ctx.instruction_bodies(definition.instruction_names))
output_schema = None
if definition.output_schema_name:
if definition.output_schema_name not in schema_registry:
raise KeyError(
f"Agent '{definition.name}' deklaruje output_schema='{definition.output_schema_name}', "
f"którego nie ma w rejestrze schematów: {sorted(schema_registry)}"
)
output_schema = schema_registry[definition.output_schema_name]
return Agent(
name=definition.name,
description=definition.description or None,
model=model_factory.for_profile(definition.model_profile, definition.temperature),
instructions=instructions,
tools=_resolve_tools(definition.tool_names, tool_registry) or None,
skills=build_skills(ctx, definition.skill_names),
output_schema=output_schema,
tool_call_limit=definition.tool_call_limit or settings.tool_call_limit,
markdown=False,
telemetry=False,
add_datetime_to_context=True,
# Nazwa roli w kontekście systemowym: pomaga modelowi trzymać się swojego zadania,
# a atrapie LLM (llm/mock_server.py) rozpoznać, który agent pyta.
add_name_to_context=True,
retries=2,
)
+137
View File
@@ -0,0 +1,137 @@
"""Odkrywanie kontekstu agentowego dostarczonego przez APM.
APM instaluje zależności do `apm_modules/<pakiet>/`, a własne prymitywy repozytorium
leżą w `.apm/`. Loader traktuje oba źródła jednolicie: prymitywy lokalne mają
pierwszeństwo przed zainstalowanymi (możliwość nadpisania skilla organizacji lokalnie).
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from .primitives import AgentPrimitive, InstructionPrimitive, PromptPrimitive, SkillRef, parse_frontmatter
@dataclass
class ApmContext:
root: Path
instructions: dict[str, InstructionPrimitive] = field(default_factory=dict)
prompts: dict[str, PromptPrimitive] = field(default_factory=dict)
agents: dict[str, AgentPrimitive] = field(default_factory=dict)
skills: dict[str, SkillRef] = field(default_factory=dict)
context_fragments: dict[str, str] = field(default_factory=dict)
packages: list[str] = field(default_factory=list)
lockfile_hash: str | None = None
# -- dostęp ------------------------------------------------------------
def prompt(self, name: str) -> PromptPrimitive:
if name not in self.prompts:
raise KeyError(f"Brak promptu '{name}' w kontekście APM. Dostępne: {sorted(self.prompts)}")
return self.prompts[name]
def agent(self, name: str) -> AgentPrimitive:
if name not in self.agents:
raise KeyError(f"Brak definicji agenta '{name}' w kontekście APM. Dostępne: {sorted(self.agents)}")
return self.agents[name]
def skill_paths(self, names: list[str]) -> list[Path]:
missing = [n for n in names if n not in self.skills]
if missing:
raise KeyError(f"Brak skilli w kontekście APM: {', '.join(missing)}. Uruchom `apm install`.")
return [self.skills[n].path for n in names]
def instruction_bodies(self, names: list[str]) -> list[str]:
missing = [n for n in names if n not in self.instructions]
if missing:
raise KeyError(f"Brak instrukcji w kontekście APM: {', '.join(missing)}")
return [self.instructions[n].body for n in names]
def context_bodies(self, names: list[str]) -> list[str]:
return [self.context_fragments[n] for n in names if n in self.context_fragments]
def summary(self) -> dict[str, object]:
"""Wpis do manifestu przebiegu - z czego dokładnie zbudowano kontekst."""
return {
"root": str(self.root),
"packages": self.packages,
"lockfile_hash": self.lockfile_hash,
"skills": sorted(self.skills),
"instructions": sorted(self.instructions),
"prompts": sorted(self.prompts),
"agents": sorted(self.agents),
}
def _load_dir(base: Path, package: str, ctx: ApmContext) -> None:
apm_dir = base / ".apm"
if not apm_dir.is_dir():
return
for path in sorted((apm_dir / "instructions").glob("*.md")):
prim = InstructionPrimitive.from_file(path, package)
ctx.instructions.setdefault(prim.name, prim)
for path in sorted((apm_dir / "prompts").glob("*.md")):
prim = PromptPrimitive.from_file(path, package)
ctx.prompts.setdefault(prim.name, prim)
for path in sorted((apm_dir / "agents").glob("*.md")):
prim = AgentPrimitive.from_file(path, package)
ctx.agents.setdefault(prim.name, prim)
for path in sorted((apm_dir / "context").glob("*.md")):
_, body = parse_frontmatter(path.read_text(encoding="utf-8"))
ctx.context_fragments.setdefault(path.stem, body)
skills_dir = apm_dir / "skills"
if skills_dir.is_dir():
for folder in sorted(p for p in skills_dir.iterdir() if p.is_dir()):
skill_md = folder / "SKILL.md"
if not skill_md.exists():
continue
meta, _ = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
name = str(meta.get("name") or folder.name)
ctx.skills.setdefault(
name, SkillRef(name=name, path=folder, description=str(meta.get("description", "")), package=package)
)
def load_apm_context(root: Path | str = ".") -> ApmContext:
"""Buduje kontekst z `.apm/` repozytorium oraz z zainstalowanych `apm_modules/`.
Kolejność ma znaczenie: najpierw lokalne (wygrywają przy konflikcie nazw),
potem zainstalowane pakiety.
"""
root = Path(root).resolve()
ctx = ApmContext(root=root)
_load_dir(root, package="local", ctx=ctx)
modules = root / "apm_modules"
if modules.is_dir():
for pkg_dir in sorted(p for p in modules.rglob("*") if (p / ".apm").is_dir()):
_load_dir(pkg_dir, package=str(pkg_dir.relative_to(modules)), ctx=ctx)
ctx.packages.append(str(pkg_dir.relative_to(modules)))
lock = root / "apm.lock.yaml"
if lock.exists():
raw = lock.read_bytes()
ctx.lockfile_hash = hashlib.sha256(raw).hexdigest()[:16]
try:
data = yaml.safe_load(raw) or {}
deps = data.get("dependencies") or {}
if isinstance(deps, dict):
ctx.packages = sorted({*ctx.packages, *deps.keys()})
except yaml.YAMLError:
pass
if not ctx.agents:
raise RuntimeError(
f"Nie znaleziono definicji agentów w {root}/.apm/agents. "
"Sieć agentowa jest konfigurowana przez APM - bez kontekstu nie ma czego uruchomić."
)
return ctx
+141
View File
@@ -0,0 +1,141 @@
"""Parsowanie prymitywów APM (pliki Markdown z frontmatterem YAML)."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""Rozdziela dokument na frontmatter i treść. Brak frontmattera nie jest błędem."""
match = _FRONTMATTER.match(text)
if not match:
return {}, text.strip()
try:
meta = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError as exc: # pragma: no cover - defensywnie
raise ValueError(f"Niepoprawny frontmatter YAML: {exc}") from exc
if not isinstance(meta, dict):
raise ValueError("Frontmatter musi być mapą klucz-wartość")
return meta, match.group(2).strip()
@dataclass
class Primitive:
name: str
description: str
body: str
path: Path
meta: dict[str, Any] = field(default_factory=dict)
package: str = "local"
@classmethod
def from_file(cls, path: Path, package: str = "local") -> Primitive:
meta, body = parse_frontmatter(path.read_text(encoding="utf-8"))
name = meta.get("name") or path.stem.split(".")[0]
return cls(
name=str(name),
description=str(meta.get("description", "")),
body=body,
path=path,
meta=meta,
package=package,
)
@dataclass
class InstructionPrimitive(Primitive):
"""Reguła zawsze aktywna dla plików pasujących do `applyTo`."""
@property
def apply_to(self) -> str:
return str(self.meta.get("applyTo", "**/*"))
@dataclass
class PromptPrimitive(Primitive):
"""Szablon zadania. Placeholdery w formacie {{nazwa}}."""
@property
def inputs(self) -> list[dict[str, Any]]:
raw = self.meta.get("inputs") or []
return [i for i in raw if isinstance(i, dict)]
def required_inputs(self) -> list[str]:
return [str(i["name"]) for i in self.inputs if i.get("required") and "name" in i]
def render(self, values: dict[str, Any], strict: bool = True) -> str:
"""Podstawia wartości pod placeholdery.
strict=True wymusza obecność wszystkich pól oznaczonych jako `required` -
brak wsadu jest błędem konfiguracji pipeline'u, nie problemem do zgadnięcia przez model.
"""
if strict:
missing = [n for n in self.required_inputs() if not values.get(n)]
if missing:
raise ValueError(f"Prompt '{self.name}': brak wymaganych wejść: {', '.join(missing)}")
def _sub(match: re.Match[str]) -> str:
key = match.group(1).strip()
value = values.get(key)
return "" if value is None else str(value)
rendered = re.sub(r"\{\{([^}]+)\}\}", _sub, self.body)
# sprzątanie po pustych sekcjach opcjonalnych
return re.sub(r"\n{3,}", "\n\n", rendered).strip()
@dataclass
class AgentPrimitive(Primitive):
"""Deklaratywna definicja agenta - topologia sieci mieszka w APM, nie w kodzie."""
@property
def model_profile(self) -> str:
return str(self.meta.get("model_profile", "planner"))
@property
def temperature(self) -> float | None:
value = self.meta.get("temperature")
return None if value is None else float(value)
@property
def tool_names(self) -> list[str]:
return [str(t) for t in (self.meta.get("tools") or [])]
@property
def skill_names(self) -> list[str]:
return [str(s) for s in (self.meta.get("skills") or [])]
@property
def instruction_names(self) -> list[str]:
return [str(i) for i in (self.meta.get("instructions") or [])]
@property
def context_names(self) -> list[str]:
return [str(c) for c in (self.meta.get("context") or [])]
@property
def output_schema_name(self) -> str | None:
value = self.meta.get("output_schema")
return None if value is None else str(value)
@property
def tool_call_limit(self) -> int | None:
value = self.meta.get("tool_call_limit")
return None if value is None else int(value)
@dataclass
class SkillRef:
"""Skill jako katalog zgodny ze specyfikacją Agent Skills - agno ładuje go natywnie."""
name: str
path: Path
description: str = ""
package: str = "local"
+127
View File
@@ -0,0 +1,127 @@
"""Interfejs wiersza poleceń - punkt wejścia dla joba GitLab CI i dla developera."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from .apm.loader import load_apm_context
from .config import Settings
from .schemas import ChangeRequest, TaskType
from .workflow.runner import run_pipeline
app = typer.Typer(add_completion=False, help="Sieć agentowa modyfikująca kod (agno + APM + GitLab CI)")
console = Console()
EXIT_CODES = {"success": 0, "no_changes": 0, "blocked": 3, "failed": 1, "running": 1}
@app.command()
def run(
repo: Path = typer.Option(..., "--repo", help="Ścieżka do repozytorium docelowego"),
package: str | None = typer.Option(None, "--package", help="Pakiet do podniesienia (np. acme-sdk)"),
to_version: str | None = typer.Option(None, "--to-version", help="Wersja docelowa"),
from_version: str | None = typer.Option(None, "--from-version", help="Wersja aktualna (opcjonalnie)"),
module: str | None = typer.Option(None, "--module", help="Nazwa modułu importu, jeśli inna niż pakiet"),
prompt: str = typer.Option("sdk-upgrade", "--prompt", help="Nazwa promptu APM stanowiącego wsad zadania"),
constraints: str = typer.Option("", "--constraints", help="Dodatkowe ograniczenia z issue / ADR"),
issue: str | None = typer.Option(None, "--issue", help="Numer issue do skomentowania po publikacji"),
offline: bool = typer.Option(False, "--offline", help="Tryb deterministyczny: reguły codemod zamiast LLM"),
plan_only: bool = typer.Option(False, "--plan-only", help="Zatrzymaj się na planie, nie zmieniaj kodu"),
in_place: bool = typer.Option(False, "--in-place", help="Pracuj na wskazanym katalogu zamiast na kopii"),
publish: bool = typer.Option(False, "--publish", help="Utwórz gałąź, wypchnij i otwórz merge request"),
target_branch: str = typer.Option("main", "--target-branch"),
verify: str | None = typer.Option(None, "--verify-command", help="Nadpisanie komendy weryfikacji"),
run_dir: Path | None = typer.Option(None, "--run-dir", help="Katalog artefaktów przebiegu"),
apm_root: Path | None = typer.Option(None, "--apm-root", help="Katalog z .apm/ i apm_modules/"),
) -> None:
"""Uruchamia pełny przebieg sieci agentowej."""
settings = Settings.from_env()
if run_dir:
settings.run_dir = run_dir
if apm_root:
settings.apm_root = apm_root.resolve()
request = ChangeRequest(
task_type=TaskType.SDK_UPGRADE if package else TaskType.CUSTOM,
prompt_name=prompt,
repo_path=str(repo),
package=package,
module_name=module,
from_version=from_version,
to_version=to_version,
constraints=constraints,
issue_ref=issue,
)
manifest = run_pipeline(
request=request,
settings=settings,
mode="offline" if offline else "llm",
in_place=in_place,
plan_only=plan_only,
publish=publish,
target_branch=target_branch,
verify_command=verify.split() if verify else None,
)
_print_summary(manifest, settings)
raise typer.Exit(EXIT_CODES.get(manifest.status, 1))
@app.command()
def context(
apm_root: Path = typer.Option(Path("."), "--apm-root", help="Katalog z .apm/ i apm_modules/"),
as_json: bool = typer.Option(False, "--json"),
) -> None:
"""Pokazuje, jaki kontekst agentowy dostarczyło APM. Używane jako bramka w CI."""
ctx = load_apm_context(apm_root)
if as_json:
console.print_json(json.dumps(ctx.summary(), ensure_ascii=False))
return
table = Table(title=f"Kontekst APM ({ctx.root})", show_lines=False)
table.add_column("Prymityw")
table.add_column("Nazwa")
table.add_column("Pakiet")
for name, prim in sorted(ctx.agents.items()):
table.add_row("agent", name, prim.package)
for name, ref in sorted(ctx.skills.items()):
table.add_row("skill", name, ref.package)
for name, prim in sorted(ctx.prompts.items()):
table.add_row("prompt", name, prim.package)
for name, prim in sorted(ctx.instructions.items()):
table.add_row("instruction", name, prim.package)
console.print(table)
console.print(f"lock: [bold]{ctx.lockfile_hash or 'brak apm.lock.yaml'}[/bold]")
def _print_summary(manifest, settings: Settings) -> None:
color = {"success": "green", "no_changes": "yellow", "blocked": "yellow", "failed": "red"}.get(
manifest.status, "white"
)
console.print(f"\n[bold {color}]status: {manifest.status}[/bold {color}] run_id={manifest.run_id}")
console.print(
f"tryb: {manifest.mode} | iteracje: {manifest.iterations} | wywołania narzędzi: {len(manifest.tool_calls)}"
)
if manifest.changed_files:
console.print("zmienione pliki:")
for path in manifest.changed_files:
console.print(f" - {path}")
if manifest.verifications:
last = manifest.verifications[-1]
console.print(f"weryfikacja: {last.command} -> rc={last.returncode} ({last.duration_s}s)")
if manifest.review:
console.print(f"recenzja: {manifest.review.verdict} - {manifest.review.summary}")
for error in manifest.errors:
console.print(f"[red]![/red] {error}")
console.print(f"artefakty: {settings.run_dir}")
if __name__ == "__main__": # pragma: no cover
sys.exit(app())
+86
View File
@@ -0,0 +1,86 @@
"""Konfiguracja przebiegu - wyłącznie ze zmiennych środowiskowych.
W GitLab CI zmienne pochodzą z ustawień projektu/grupy (masked + protected).
Kod nigdy nie czyta sekretów z plików w repozytorium.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
# Ścieżki, których agent nie może dotknąć niezależnie od treści planu.
# To ostatnia linia obrony - nie polegamy na tym, że model przeczyta guardraile.
DEFAULT_DENY_GLOBS: tuple[str, ...] = (
".git/**",
".gitlab-ci.yml",
".gitlab/**",
".github/workflows/**",
"Dockerfile*",
"**/Chart.yaml",
"**/*.pem",
"**/*.key",
"**/*secret*",
".env",
".env.*",
"apm-policy.yml",
"apm.lock.yaml",
)
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
@dataclass
class ModelProfiles:
"""Model per rola - planista może być większy niż redaktor opisu MR."""
planner: str = field(default_factory=lambda: _env("CODEMOD_MODEL_PLANNER", "Qwen/Qwen3-32B"))
coder: str = field(default_factory=lambda: _env("CODEMOD_MODEL_CODER", "Qwen/Qwen3-Coder-30B"))
reviewer: str = field(default_factory=lambda: _env("CODEMOD_MODEL_REVIEWER", "Qwen/Qwen3-32B"))
scribe: str = field(default_factory=lambda: _env("CODEMOD_MODEL_SCRIBE", "Qwen/Qwen3-8B"))
def get(self, profile: str) -> str:
return getattr(self, profile, self.planner)
def as_dict(self) -> dict[str, str]:
return {"planner": self.planner, "coder": self.coder, "reviewer": self.reviewer, "scribe": self.scribe}
@dataclass
class Settings:
# --- backend LLM ---
provider: str = field(default_factory=lambda: _env("CODEMOD_MODEL_PROVIDER", "vllm"))
base_url: str = field(default_factory=lambda: _env("CODEMOD_LLM_BASE_URL", "http://localhost:8000/v1"))
api_key: str = field(default_factory=lambda: _env("CODEMOD_LLM_API_KEY", "not-required"))
models: ModelProfiles = field(default_factory=ModelProfiles)
# --- budżety i guardraile ---
max_iterations: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_ITERATIONS", 4))
max_files_changed: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_FILES_CHANGED", 40))
max_file_bytes: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_FILE_BYTES", 400_000))
tool_call_limit: int = field(default_factory=lambda: _int_env("CODEMOD_TOOL_CALL_LIMIT", 60))
verify_timeout_s: int = field(default_factory=lambda: _int_env("CODEMOD_VERIFY_TIMEOUT_S", 900))
deny_globs: tuple[str, ...] = DEFAULT_DENY_GLOBS
# --- GitLab ---
gitlab_url: str = field(default_factory=lambda: _env("CI_SERVER_URL", ""))
gitlab_project_id: str = field(default_factory=lambda: _env("CI_PROJECT_ID", ""))
gitlab_token: str = field(default_factory=lambda: _env("CODEMOD_GITLAB_TOKEN", ""))
# --- ścieżki ---
apm_root: Path = field(default_factory=lambda: Path(_env("CODEMOD_APM_ROOT", ".")).resolve())
run_dir: Path = field(default_factory=lambda: Path(_env("CODEMOD_RUN_DIR", ".runs/current")))
@classmethod
def from_env(cls) -> Settings:
return cls()
@@ -0,0 +1,77 @@
"""Minimalny klient GitLaba - tylko to, czego pipeline naprawdę potrzebuje.
Świadomie bez `python-gitlab`: mniejsza powierzchnia zależności w obrazie runnera
i pełna kontrola nad tym, jakie wywołania API wykonuje job modyfikujący kod.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..config import Settings
@dataclass
class MergeRequestRef:
iid: int | None
web_url: str | None
created: bool
detail: str = ""
class GitLabClient:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.enabled = bool(settings.gitlab_url and settings.gitlab_project_id and settings.gitlab_token)
@property
def _api(self) -> str:
return f"{self.settings.gitlab_url.rstrip('/')}/api/v4"
def _headers(self) -> dict[str, str]:
return {"PRIVATE-TOKEN": self.settings.gitlab_token, "Content-Type": "application/json"}
def create_merge_request(
self,
source_branch: str,
target_branch: str,
title: str,
description: str,
labels: list[str] | None = None,
remove_source_branch: bool = True,
) -> MergeRequestRef:
if not self.enabled:
return MergeRequestRef(None, None, False, "Brak konfiguracji GitLaba - pominięto tworzenie MR.")
payload: dict[str, Any] = {
"source_branch": source_branch,
"target_branch": target_branch,
"title": title,
"description": description,
"labels": ",".join(labels or ["agentic-codemod"]),
"remove_source_branch": remove_source_branch,
"squash": True,
}
response = httpx.post(
f"{self._api}/projects/{self.settings.gitlab_project_id}/merge_requests",
headers=self._headers(),
json=payload,
timeout=30,
)
if response.status_code >= 400:
return MergeRequestRef(None, None, False, f"HTTP {response.status_code}: {response.text[:300]}")
data = response.json()
return MergeRequestRef(iid=data.get("iid"), web_url=data.get("web_url"), created=True)
def comment_on_issue(self, issue_iid: str | int, body: str) -> bool:
if not self.enabled:
return False
response = httpx.post(
f"{self._api}/projects/{self.settings.gitlab_project_id}/issues/{issue_iid}/notes",
headers=self._headers(),
json={"body": body},
timeout=30,
)
return response.status_code < 400
+47
View File
@@ -0,0 +1,47 @@
"""Fabryka modeli - jedyne miejsce, które wie o dostawcy LLM.
Domyślnie self-hosted vLLM z API zgodnym z OpenAI. Ollama jako ścieżka zapasowa
(lokalny development, środowisko bez GPU). Wymiana dostawcy = zmiana zmiennej
środowiskowej, nie zmiana kodu agentów.
"""
from __future__ import annotations
from typing import Any
from .config import Settings
class ModelFactory:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._cache: dict[tuple[str, float | None], Any] = {}
def for_profile(self, profile: str, temperature: float | None = None) -> Any:
model_id = self.settings.models.get(profile)
key = (model_id, temperature)
if key not in self._cache:
self._cache[key] = self._build(model_id, temperature)
return self._cache[key]
def _build(self, model_id: str, temperature: float | None) -> Any:
provider = self.settings.provider.lower()
kwargs: dict[str, Any] = {"id": model_id}
if temperature is not None:
kwargs["temperature"] = temperature
if provider in {"vllm", "openai_like", "openai"}:
from agno.models.openai.like import OpenAILike
return OpenAILike(
base_url=self.settings.base_url,
api_key=self.settings.api_key or "not-required",
**kwargs,
)
if provider == "ollama":
from agno.models.ollama import Ollama
host = self.settings.base_url.removesuffix("/v1").removesuffix("/v1/")
return Ollama(host=host or None, **kwargs)
raise ValueError(f"Nieznany dostawca modelu: '{provider}'. Obsługiwane: vllm, openai_like, ollama.")
@@ -0,0 +1,65 @@
"""Ślad audytowy przebiegu.
W środowisku regulowanym (DORA, EU AI Act) trzeba umieć odtworzyć: kto zlecił zmianę,
jaki kontekst dostał model, jakie narzędzia wywołał, co dokładnie zmienił i kto to zatwierdził.
Każde wywołanie narzędzia agenta przechodzi przez `AuditLog.record`.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Wzorce redakcji - log audytowy jest artefaktem CI i bywa czytany szeroko.
_SECRET_PATTERNS = [
re.compile(r"(?i)(api[_-]?key|token|password|secret|authorization)\s*[:=]\s*['\"]?([^\s'\"]{6,})"),
re.compile(r"glpat-[A-Za-z0-9_\-]{10,}"),
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{10,}"),
]
def redact(text: str) -> str:
out = text
for pattern in _SECRET_PATTERNS:
out = pattern.sub(
lambda m: (
(m.group(0)[: m.start(2) - m.start(0)] + "***REDACTED***") if m.re.groups >= 2 else "***REDACTED***"
),
out,
)
return out
@dataclass
class AuditLog:
run_dir: Path
entries: list[dict[str, Any]] = field(default_factory=list)
_started: float = field(default_factory=time.monotonic)
def __post_init__(self) -> None:
self.run_dir = Path(self.run_dir)
self.run_dir.mkdir(parents=True, exist_ok=True)
self._path = self.run_dir / "trace.jsonl"
def record(self, tool: str, args: dict[str, Any], ok: bool = True, detail: str = "") -> None:
entry = {
"t": round(time.monotonic() - self._started, 3),
"tool": tool,
"args": {k: redact(str(v))[:400] for k, v in args.items()},
"ok": ok,
"detail": redact(detail)[:600],
}
self.entries.append(entry)
with self._path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
def event(self, name: str, **payload: Any) -> None:
self.record(tool=f"event:{name}", args=payload)
@property
def tool_call_count(self) -> int:
return sum(1 for e in self.entries if not e["tool"].startswith("event:"))
+125
View File
@@ -0,0 +1,125 @@
"""Kontrakty danych między krokami pipeline'u.
Każdy agent zwraca strukturę z tego modułu (`output_schema` w definicji `.agent.md`).
Dzięki temu granice między agentami typowane, a nie "tekstowe" - to warunek
powtarzalności przebiegu i sensownego audytu.
"""
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Literal
from pydantic import BaseModel, Field
class TaskType(str, Enum):
SDK_UPGRADE = "sdk_upgrade"
DEPENDENCY_AUDIT = "dependency_audit"
CUSTOM = "custom"
class ChangeRequest(BaseModel):
"""Wsad zadania - powstaje deterministycznie z CLI / zmiennych CI / issue."""
task_type: TaskType = TaskType.SDK_UPGRADE
prompt_name: str = "sdk-upgrade"
repo_path: str
package: str | None = None
module_name: str | None = Field(default=None, description="Nazwa modułu importu, jeśli różna od nazwy pakietu")
from_version: str | None = None
to_version: str | None = None
constraints: str = ""
issue_ref: str | None = None
requested_by: str | None = None
class RepoProfile(BaseModel):
"""Fakty o repozytorium ustalone przed planowaniem."""
build_system: str | None = None
dependency_files: list[str] = Field(default_factory=list)
declared_version: str | None = None
module_name: str | None = None
usage_files: list[str] = Field(default_factory=list)
usage_symbols: list[str] = Field(default_factory=list)
verify_command: str | None = None
blast_radius: Literal["low", "medium", "high", "unknown"] = "unknown"
gaps: list[str] = Field(default_factory=list)
class PlannedEdit(BaseModel):
order: int
path: str
intent: str = Field(description="Co dokładnie ma się zmienić w tym pliku")
rationale: str = ""
acceptance_criteria: str = Field(
default="", description="Sprawdzalne kryterium, po którym poznamy że edycja jest poprawna"
)
requires_human: bool = False
blocked_reason: str | None = None
class ChangePlan(BaseModel):
summary: str
edits: list[PlannedEdit] = Field(default_factory=list)
out_of_scope: list[str] = Field(default_factory=list)
risks: list[str] = Field(default_factory=list)
@property
def actionable_edits(self) -> list[PlannedEdit]:
return sorted((e for e in self.edits if not e.requires_human), key=lambda e: e.order)
@property
def blocked_edits(self) -> list[PlannedEdit]:
return [e for e in self.edits if e.requires_human]
class VerificationResult(BaseModel):
command: str
returncode: int
passed: bool
duration_s: float = 0.0
output_tail: str = ""
timed_out: bool = False
class Finding(BaseModel):
severity: Literal["info", "warning", "blocker"] = "warning"
category: str = "other"
path: str | None = None
message: str
class ReviewVerdict(BaseModel):
verdict: Literal["approve", "request_changes"] = "request_changes"
summary: str = ""
findings: list[Finding] = Field(default_factory=list)
class MergeRequestDraft(BaseModel):
title: str
description: str
class RunManifest(BaseModel):
"""Artefakt audytowy przebiegu - jeden plik JSON na uruchomienie pipeline'u."""
run_id: str
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: datetime | None = None
status: Literal["running", "success", "failed", "blocked", "no_changes"] = "running"
mode: Literal["offline", "llm"] = "llm"
request: ChangeRequest | None = None
profile: RepoProfile | None = None
plan: ChangePlan | None = None
verifications: list[VerificationResult] = Field(default_factory=list)
review: ReviewVerdict | None = None
merge_request: MergeRequestDraft | None = None
changed_files: list[str] = Field(default_factory=list)
iterations: int = 0
models: dict[str, str] = Field(default_factory=dict)
apm_context: dict[str, Any] = Field(default_factory=dict)
tool_calls: list[dict[str, Any]] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
+35
View File
@@ -0,0 +1,35 @@
"""Rejestr narzędzi udostępnianych agentom.
Definicja agenta w `.apm/agents/*.agent.md` wymienia narzędzia po nazwie. Rejestr
tłumaczy te nazwy na konkretne, sandboxowane funkcje. Narzędzie nieobecne w rejestrze
= twardy błąd konfiguracji, a nie ciche pominięcie.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from .vcs import GitRepo
from .verification import VerificationRunner
from .workspace import WorkspaceError, WorkspaceTools
def build_tool_registry(
workspace: WorkspaceTools,
verification: VerificationRunner | None = None,
) -> dict[str, Callable[..., Any]]:
registry: dict[str, Callable[..., Any]] = {
"list_files": workspace.list_files,
"read_file": workspace.read_file,
"search_repo": workspace.search_repo,
"replace_in_file": workspace.replace_in_file,
"write_file": workspace.write_file,
"get_diff": workspace.get_diff,
}
if verification is not None:
registry["run_verification"] = verification.run_verification
return registry
__all__ = ["GitRepo", "VerificationRunner", "WorkspaceError", "WorkspaceTools", "build_tool_registry"]
+71
View File
@@ -0,0 +1,71 @@
"""Operacje git - wykonywane deterministycznie przez pipeline, nie przez model.
Agent może co najwyżej obejrzeć diff (`get_diff` w WorkspaceTools). Tworzenie gałęzi,
commit i push krokami pipeline'u, żeby historia repozytorium miała jednoznacznego autora
i przewidywalny format.
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from pathlib import Path
class GitError(RuntimeError):
pass
@dataclass
class GitRepo:
root: Path
def __post_init__(self) -> None:
self.root = Path(self.root).resolve()
def _run(self, *args: str, check: bool = True) -> str:
proc = subprocess.run(["git", *args], cwd=self.root, capture_output=True, text=True, timeout=120)
if check and proc.returncode != 0:
raise GitError(f"git {' '.join(args)} zakończone kodem {proc.returncode}: {proc.stderr.strip()}")
return proc.stdout.strip()
def is_repo(self) -> bool:
return (self.root / ".git").exists()
def init_if_needed(self) -> None:
if not self.is_repo():
self._run("init", "-q")
self._run("config", "user.email", "agentic-codemod@pipeline.local")
self._run("config", "user.name", "Agentic Codemod")
self._run("add", "-A")
self._run("commit", "-qm", "baseline")
def current_branch(self) -> str:
return self._run("rev-parse", "--abbrev-ref", "HEAD")
def changed_files(self) -> list[str]:
self._run("add", "-AN", check=False)
output = self._run("diff", "--name-only")
return [line for line in output.splitlines() if line]
def diff(self, max_chars: int = 200_000) -> str:
self._run("add", "-AN", check=False)
return self._run("diff")[:max_chars]
def create_branch(self, name: str) -> str:
self._run("checkout", "-q", "-B", name)
return name
def commit_all(self, message: str, author: str = "Agentic Codemod <agentic-codemod@pipeline.local>") -> str:
self._run("add", "-A")
if not self._run("diff", "--cached", "--name-only"):
raise GitError("Brak zmian do zacommitowania")
self._run("commit", "-q", "-m", message, f"--author={author}")
return self._run("rev-parse", "HEAD")
def push(self, remote: str = "origin", branch: str | None = None, force: bool = False) -> str:
branch = branch or self.current_branch()
args = ["push", "-q", remote, f"HEAD:refs/heads/{branch}"]
if force:
args.insert(1, "--force-with-lease")
return self._run(*args)
+103
View File
@@ -0,0 +1,103 @@
"""Weryfikacja repozytorium - jedyna dopuszczalna forma "uruchamiania czegokolwiek" przez agenta.
Agent nie dostaje powłoki. Dostaje jedną komendę, ustaloną deterministycznie przez adapter
ekosystemu (lub przez profil repozytorium), z twardym limitem czasu.
"""
from __future__ import annotations
import subprocess
import time
from pathlib import Path
from ..adapters.base import EcosystemAdapter
from ..config import Settings
from ..observability.audit import AuditLog
from ..schemas import VerificationResult
def _tail(text: str, limit: int = 4000) -> str:
text = text.strip()
return text if len(text) <= limit else "...\n" + text[-limit:]
class VerificationRunner:
def __init__(
self,
root: Path | str,
adapter: EcosystemAdapter,
settings: Settings,
audit: AuditLog,
command: list[str] | None = None,
) -> None:
self.root = Path(root).resolve()
self.adapter = adapter
self.settings = settings
self.audit = audit
self.command = command or adapter.verify_command()
self.results: list[VerificationResult] = []
def run(self) -> VerificationResult:
started = time.monotonic()
try:
proc = subprocess.run(
self.command,
cwd=self.root,
capture_output=True,
text=True,
timeout=self.settings.verify_timeout_s,
env=self._env(),
)
result = VerificationResult(
command=" ".join(self.command),
returncode=proc.returncode,
passed=proc.returncode == 0,
duration_s=round(time.monotonic() - started, 2),
output_tail=_tail(proc.stdout + "\n" + proc.stderr),
)
except subprocess.TimeoutExpired:
result = VerificationResult(
command=" ".join(self.command),
returncode=-1,
passed=False,
duration_s=round(time.monotonic() - started, 2),
output_tail=f"Przekroczono limit czasu {self.settings.verify_timeout_s}s",
timed_out=True,
)
except FileNotFoundError as exc:
result = VerificationResult(
command=" ".join(self.command),
returncode=-2,
passed=False,
output_tail=f"Nie znaleziono narzędzia weryfikacji: {exc}",
)
self.results.append(result)
self.audit.record(
"run_verification",
{"command": result.command},
ok=result.passed,
detail=f"rc={result.returncode} czas={result.duration_s}s",
)
return result
def _env(self) -> dict[str, str]:
import os
env = dict(os.environ)
# Środowisko weryfikacji nie ma dostępu do sekretów pipeline'u.
for key in list(env):
if any(marker in key.upper() for marker in ("TOKEN", "SECRET", "PASSWORD", "API_KEY")):
env.pop(key, None)
env.setdefault("PYTHONDONTWRITEBYTECODE", "1")
return env
# ----------------------------------------------------------------- tool
def run_verification(self) -> str:
"""Uruchamia build i testy repozytorium. Wywołuj po każdej zmianie pliku.
Zwraca wynik wraz z końcówką logu. Czerwony wynik napraw natychmiast,
zanim przejdziesz do kolejnego pliku.
"""
result = self.run()
status = "ZIELONO" if result.passed else "CZERWONO"
return f"[{status}] {result.command} (rc={result.returncode}, {result.duration_s}s)\n\n{result.output_tail}"
+206
View File
@@ -0,0 +1,206 @@
"""Sandboxowane narzędzia plikowe - jedyny kanał kontaktu agenta z repozytorium.
Model nigdy nie dostaje powłoki. Każda operacja jest:
- ograniczona do katalogu repozytorium (brak wyjścia przez `..` i dowiązania),
- sprawdzana względem listy zakazanych ścieżek (pipeline, sekrety, manifesty),
- limitowana rozmiarem i liczbą zmienionych plików,
- zapisywana w śladzie audytowym.
Guardraile z instrukcji APM dla modelu. Ten moduł jest dla audytora.
"""
from __future__ import annotations
import fnmatch
import subprocess
from pathlib import Path
from ..config import Settings
from ..observability.audit import AuditLog
_TEXT_SUFFIXES = {
".py",
".java",
".kt",
".js",
".ts",
".tsx",
".go",
".rb",
".rs",
".sql",
".toml",
".cfg",
".ini",
".txt",
".md",
".yaml",
".yml",
".json",
".xml",
".gradle",
".properties",
".sh",
".tf",
"",
}
class WorkspaceError(RuntimeError):
"""Błąd zwracany agentowi jako czytelny komunikat, nie jako wyjątek przerywający przebieg."""
class WorkspaceTools:
def __init__(self, root: Path | str, settings: Settings, audit: AuditLog) -> None:
self.root = Path(root).resolve()
if not self.root.is_dir():
raise WorkspaceError(f"Katalog repozytorium nie istnieje: {self.root}")
self.settings = settings
self.audit = audit
self.changed_files: set[str] = set()
# ------------------------------------------------------------------ util
def _resolve(self, path: str, for_write: bool = False) -> Path:
candidate = (self.root / path).resolve()
if not candidate.is_relative_to(self.root):
raise WorkspaceError(f"Ścieżka poza repozytorium jest zabroniona: {path}")
relative = candidate.relative_to(self.root).as_posix()
for pattern in self.settings.deny_globs:
if fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(relative, pattern.replace("**/", "")):
raise WorkspaceError(
f"Ścieżka '{relative}' jest objęta zakazem modyfikacji (guardrail: {pattern}). "
"Zgłoś potrzebę zmiany jako requires_human."
)
if for_write and len(self.changed_files | {relative}) > self.settings.max_files_changed:
raise WorkspaceError(
f"Przekroczony budżet zmienionych plików ({self.settings.max_files_changed}). "
"Zakres zmiany jest zbyt szeroki - zatrzymaj się i zgłoś to w podsumowaniu."
)
return candidate
# ----------------------------------------------------------------- tools
def list_files(self, subdirectory: str = ".", pattern: str = "*") -> str:
"""Wypisuje pliki repozytorium. Użyj do rozpoznania struktury projektu.
Args:
subdirectory: katalog względem korzenia repozytorium (domyślnie cały projekt).
pattern: wzorzec glob nazwy pliku, np. '*.py'.
"""
base = self._resolve(subdirectory)
results: list[str] = []
for path in sorted(base.rglob(pattern)):
if not path.is_file() or any(
part in {".git", "__pycache__", ".venv", "node_modules"} for part in path.parts
):
continue
results.append(path.relative_to(self.root).as_posix())
if len(results) >= 500:
results.append("... (lista obcięta do 500 pozycji)")
break
self.audit.record(
"list_files", {"subdirectory": subdirectory, "pattern": pattern}, detail=f"{len(results)} plików"
)
return "\n".join(results) or "(brak plików)"
def read_file(self, path: str) -> str:
"""Zwraca zawartość pliku z numerami linii. Zawsze czytaj plik przed jego edycją.
Args:
path: ścieżka względem korzenia repozytorium.
"""
target = self._resolve(path)
if not target.is_file():
self.audit.record("read_file", {"path": path}, ok=False, detail="brak pliku")
raise WorkspaceError(f"Plik nie istnieje: {path}")
if target.stat().st_size > self.settings.max_file_bytes:
raise WorkspaceError(f"Plik {path} przekracza limit {self.settings.max_file_bytes} bajtów")
content = target.read_text(encoding="utf-8", errors="replace")
self.audit.record("read_file", {"path": path}, detail=f"{len(content)} znaków")
numbered = "\n".join(f"{i:>4}| {line}" for i, line in enumerate(content.splitlines(), start=1))
return numbered or "(plik pusty)"
def search_repo(self, pattern: str, file_glob: str = "*") -> str:
"""Wyszukuje wzorzec (regex) w repozytorium i zwraca dopasowania z numerami linii.
Args:
pattern: wyrażenie regularne, np. 'from acme import'.
file_glob: ograniczenie do typu plików, np. '*.py'.
"""
command = [
"grep",
"-rniE",
"--line-number",
f"--include={file_glob}",
"--exclude-dir=.git",
"--exclude-dir=__pycache__",
"--exclude-dir=.venv",
"--exclude-dir=node_modules",
pattern,
".",
]
proc = subprocess.run(command, cwd=self.root, capture_output=True, text=True, timeout=60)
output = proc.stdout.strip()
lines = output.splitlines()[:200]
self.audit.record("search_repo", {"pattern": pattern, "file_glob": file_glob}, detail=f"{len(lines)} dopasowań")
return "\n".join(lines) or "(brak dopasowań)"
def replace_in_file(self, path: str, old_text: str, new_text: str) -> str:
"""Zastępuje dokładnie jedno wystąpienie fragmentu w pliku. Podstawowe narzędzie edycji.
Fragment `old_text` musi być unikalny w pliku i przepisany co do znaku (wraz z wcięciami).
Jeśli fragment występuje wielokrotnie - poszerz go o sąsiednie linie.
Args:
path: ścieżka względem korzenia repozytorium.
old_text: dokładny fragment do zastąpienia.
new_text: nowa treść fragmentu.
"""
target = self._resolve(path, for_write=True)
if not target.is_file():
raise WorkspaceError(f"Plik nie istnieje: {path}")
content = target.read_text(encoding="utf-8")
occurrences = content.count(old_text)
if occurrences == 0:
self.audit.record("replace_in_file", {"path": path}, ok=False, detail="brak dopasowania")
raise WorkspaceError(
f"Nie znaleziono podanego fragmentu w {path}. Odczytaj plik ponownie i przepisz fragment dokładnie."
)
if occurrences > 1:
self.audit.record("replace_in_file", {"path": path}, ok=False, detail=f"{occurrences} dopasowań")
raise WorkspaceError(
f"Fragment występuje {occurrences} razy w {path}. Poszerz go o sąsiednie linie, aby był jednoznaczny."
)
target.write_text(content.replace(old_text, new_text, 1), encoding="utf-8")
relative = target.relative_to(self.root).as_posix()
self.changed_files.add(relative)
self.audit.record("replace_in_file", {"path": path}, detail="ok")
return f"Zmieniono {relative}."
def write_file(self, path: str, content: str) -> str:
"""Zapisuje plik w całości. Używaj wyłącznie dla plików nowych - do edycji służy replace_in_file.
Args:
path: ścieżka względem korzenia repozytorium.
content: pełna treść pliku.
"""
target = self._resolve(path, for_write=True)
if target.suffix not in _TEXT_SUFFIXES:
raise WorkspaceError(f"Niedozwolony typ pliku do zapisu: {target.suffix}")
if len(content.encode("utf-8")) > self.settings.max_file_bytes:
raise WorkspaceError("Treść przekracza limit rozmiaru pliku")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
relative = target.relative_to(self.root).as_posix()
self.changed_files.add(relative)
self.audit.record("write_file", {"path": path}, detail=f"{len(content)} znaków")
return f"Zapisano {relative}."
def get_diff(self) -> str:
"""Zwraca aktualny diff repozytorium (git diff wraz z plikami nieśledzonymi)."""
subprocess.run(["git", "add", "-AN"], cwd=self.root, capture_output=True, text=True)
proc = subprocess.run(["git", "diff"], cwd=self.root, capture_output=True, text=True, timeout=60)
diff = proc.stdout
self.audit.record("get_diff", {}, detail=f"{len(diff)} znaków")
if not diff.strip():
return "(brak zmian w repozytorium)"
return diff[:60_000]
+7
View File
@@ -0,0 +1,7 @@
"""Warstwa przepływu: kontekst, kroki, topologia agno, uruchamianie."""
from .context import PipelineContext, build_context
from .network import build_workflow
from .runner import run_pipeline
__all__ = ["PipelineContext", "build_context", "build_workflow", "run_pipeline"]
+345
View File
@@ -0,0 +1,345 @@
"""Dwie implementacje "mózgu" sieci agentowej.
`LlmBrain` - agenci agno zbudowani z definicji APM; używany w normalnym przebiegu.
`RuleBrain` - deterministyczna ścieżka bez modelu: reguły codemod z pakietu APM
plus mechaniczna kontrola jakości.
Ten sam interfejs po obu stronach daje trzy rzeczy: smoke test pipeline'u w CI bez
kosztu i dostępności GPU, powtarzalny wynik dla migracji w pełni pokrytych regułami
oraz punkt odniesienia do oceny, czy model wnosi cokolwiek ponad reguły.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Protocol
from ..apm.agno_bridge import build_agent
from ..llm import ModelFactory
from ..schemas import (
ChangePlan,
Finding,
MergeRequestDraft,
PlannedEdit,
RepoProfile,
ReviewVerdict,
VerificationResult,
)
from .codemod import apply_ruleset, load_rulesets
from .context import PipelineContext
from .facts import collect_facts, facts_as_markdown
SCHEMA_REGISTRY: dict[str, type] = {
"RepoProfile": RepoProfile,
"ChangePlan": ChangePlan,
"ReviewVerdict": ReviewVerdict,
"MergeRequestDraft": MergeRequestDraft,
}
class Brain(Protocol):
def recon(self) -> RepoProfile: ...
def plan(self, profile: RepoProfile) -> ChangePlan: ...
def implement(self, plan: ChangePlan) -> str: ...
def review(self, diff: str) -> ReviewVerdict: ...
def compose_merge_request(self, diff: str, verification: VerificationResult | None) -> MergeRequestDraft: ...
# --------------------------------------------------------------------------- LLM
class LlmBrain:
def __init__(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.model_factory = ModelFactory(ctx.settings)
self._agents: dict[str, Any] = {}
def agent(self, name: str):
if name not in self._agents:
self._agents[name] = build_agent(
definition=self.ctx.apm.agent(name),
ctx=self.ctx.apm,
settings=self.ctx.settings,
model_factory=self.model_factory,
tool_registry=self.ctx.tool_registry,
schema_registry=SCHEMA_REGISTRY,
)
return self._agents[name]
def _run(self, agent_name: str, message: str, schema: type | None = None):
self.ctx.audit.event("agent_run_started", agent=agent_name, chars=len(message))
output = self.agent(agent_name).run(message)
content = getattr(output, "content", output)
if schema is not None and not isinstance(content, schema):
content = _coerce(content, schema)
self.ctx.audit.event("agent_run_finished", agent=agent_name)
return content
# -- kroki -------------------------------------------------------------
def task_brief(self) -> str:
request = self.ctx.request
prompt = self.ctx.apm.prompt(request.prompt_name)
return prompt.render(
{
"package": request.package,
"to_version": request.to_version,
"from_version": request.from_version or "nieznana",
"repo": str(self.ctx.repo_root),
"constraints": request.constraints or "brak dodatkowych ograniczeń",
"policy": request.constraints,
}
)
def recon(self) -> RepoProfile:
facts = collect_facts(self.ctx.repo_root, self.ctx.adapter, self.ctx.request)
message = (
f"{self.task_brief()}\n\n{facts_as_markdown(facts)}\n\n"
"Zweryfikuj powyższe fakty narzędziami, uzupełnij brakujące pola i zwróć profil repozytorium."
)
profile = self._run("scout", message, RepoProfile)
# fakty deterministyczne mają pierwszeństwo nad tym, co zwrócił model
profile.build_system = facts.build_system
profile.dependency_files = facts.dependency_files
profile.declared_version = facts.declared_version or profile.declared_version
profile.verify_command = facts.verify_command
return profile
def plan(self, profile: RepoProfile) -> ChangePlan:
message = (
f"{self.task_brief()}\n\n## Profil repozytorium\n```json\n"
f"{profile.model_dump_json(indent=2)}\n```\n\n"
"Zbuduj plan zmian. Deklaracja wersji zależności zostanie ustawiona automatycznie "
"przez pipeline - nie planuj edycji plików zależności."
)
return self._run("planner", message, ChangePlan)
def implement(self, plan: ChangePlan) -> str:
edits = "\n".join(
f"{edit.order}. {edit.path} - {edit.intent} (kryterium: {edit.acceptance_criteria})"
for edit in plan.actionable_edits
)
message = (
f"{self.task_brief()}\n\n## Zatwierdzony plan\n{plan.summary}\n\n{edits}\n\n"
"Wykonaj plan pozycja po pozycji. Po każdej edycji uruchom run_verification. "
"Zakończ, gdy weryfikacja jest zielona."
)
output = self._run("coder", message)
return str(output)
def review(self, diff: str) -> ReviewVerdict:
message = (
f"{self.task_brief()}\n\n## Diff do oceny\n```diff\n{diff[:40_000]}\n```\n\n"
"Wydaj werdykt zgodnie ze swoją procedurą."
)
return self._run("reviewer", message, ReviewVerdict)
def compose_merge_request(self, diff: str, verification: VerificationResult | None) -> MergeRequestDraft:
verification_block = (
f"{verification.command} -> rc={verification.returncode}\n{verification.output_tail[-1500:]}"
if verification
else "brak weryfikacji"
)
message = (
f"{self.task_brief()}\n\n## Ślad audytowy\n"
f"run_id={self.ctx.run_id}, pakiety kontekstu={self.ctx.apm.packages or ['local']}, "
f"lock={self.ctx.apm.lockfile_hash}, modele={self.ctx.settings.models.as_dict()}\n\n"
f"## Wynik weryfikacji\n```\n{verification_block}\n```\n\n"
f"## Diff\n```diff\n{diff[:30_000]}\n```"
)
return self._run("scribe", message, MergeRequestDraft)
def _coerce(content: Any, schema: type):
"""Model bywa gadatliwy mimo output_schema - ostatnia linia obrony przed śmieciem."""
if isinstance(content, schema):
return content
if isinstance(content, dict):
return schema(**content)
text = str(content).strip()
if text.startswith("```"):
text = text.strip("`")
text = text.split("\n", 1)[1] if "\n" in text else text
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
return schema(**json.loads(text[start : end + 1]))
raise ValueError(f"Nie udało się sparsować odpowiedzi agenta do schematu {schema.__name__}: {text[:300]}")
# -------------------------------------------------------------------------- reguły
class RuleBrain:
"""Ścieżka bez modelu: reguły codemod + mechaniczne kontrole."""
def __init__(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self._rulesets = self._load_rulesets()
def _load_rulesets(self):
package = self.ctx.request.package
if not package:
return []
skill_paths = [ref.path for ref in self.ctx.apm.skills.values()]
return load_rulesets(skill_paths, package)
def recon(self) -> RepoProfile:
profile = collect_facts(self.ctx.repo_root, self.ctx.adapter, self.ctx.request)
if not self._rulesets:
profile.gaps.append(
f"Brak reguł codemod dla pakietu '{self.ctx.request.package}' w kontekście APM - "
"tryb offline nie zmigruje kodu."
)
return profile
def plan(self, profile: RepoProfile) -> ChangePlan:
edits: list[PlannedEdit] = []
order = 1
for ruleset in self._rulesets:
preview = apply_ruleset(self.ctx.repo_root, ruleset, dry_run=True)
for path in preview.changed_files:
edits.append(
PlannedEdit(
order=order,
path=path,
intent=f"Zastosuj reguły migracyjne pakietu {ruleset.package}",
rationale=f"Reguły z {ruleset.source.name if ruleset.source else 'APM'}",
acceptance_criteria=f"{profile.verify_command} kończy się kodem 0",
)
)
order += 1
risks = ["Tryb deterministyczny: zmiany spoza zakresu reguł nie zostaną wykonane."]
blocked = (
[
PlannedEdit(
order=999,
path="(cały projekt)",
intent="Przypadki nieobjęte regułami codemod",
requires_human=True,
blocked_reason="Tryb offline nie używa modelu - nietypowe użycia API wymagają przebiegu z LLM.",
)
]
if not self._rulesets
else []
)
return ChangePlan(
summary=(
f"Migracja {self.ctx.request.package} -> {self.ctx.request.to_version} "
f"regułami codemod z pakietu APM ({len(edits)} plików)."
),
edits=edits + blocked,
out_of_scope=["Pliki zależności (ustawiane deterministycznie przez pipeline)"],
risks=risks,
)
def implement(self, plan: ChangePlan) -> str:
summary: list[str] = []
for ruleset in self._rulesets:
result = apply_ruleset(self.ctx.repo_root, ruleset)
for path in result.changed_files:
self.ctx.workspace.changed_files.add(path)
self.ctx.audit.record(
"codemod",
{"ruleset": str(ruleset.source), "package": ruleset.package},
detail=f"{result.total_replacements} podmian w {len(result.changed_files)} plikach",
)
summary.append(
f"{ruleset.package}: {result.total_replacements} podmian w {len(result.changed_files)} plikach; "
f"reguły bez dopasowania: {', '.join(result.remaining_rules) or 'brak'}"
)
return "\n".join(summary) or "Brak reguł do zastosowania."
def review(self, diff: str) -> ReviewVerdict:
findings: list[Finding] = []
changed = self.ctx.git.changed_files()
for path in changed:
if any(Path(path).match(pattern) for pattern in self.ctx.settings.deny_globs):
findings.append(
Finding(
severity="blocker",
category="guardrail",
path=path,
message="Zmieniono plik objęty zakazem modyfikacji.",
)
)
if "/tests/" in f"/{path}" or Path(path).name.startswith("test_"):
findings.append(
Finding(
severity="warning",
category="test_change",
path=path,
message="Zmiana w pliku testowym - wymaga uwagi recenzenta.",
)
)
if len(changed) > self.ctx.settings.max_files_changed:
findings.append(
Finding(
severity="blocker", category="scope", message=f"Zmieniono {len(changed)} plików - powyżej budżetu."
)
)
last = self.ctx.verification.results[-1] if self.ctx.verification.results else None
if last is None or not last.passed:
findings.append(
Finding(
severity="blocker", category="verification", message="Weryfikacja nie zakończyła się powodzeniem."
)
)
blockers = [f for f in findings if f.severity == "blocker"]
return ReviewVerdict(
verdict="request_changes" if blockers else "approve",
summary=(
"Kontrola mechaniczna bez zastrzeżeń blokujących." if not blockers else "Wykryto problemy blokujące."
),
findings=findings,
)
def compose_merge_request(self, diff: str, verification: VerificationResult | None) -> MergeRequestDraft:
request = self.ctx.request
changed = self.ctx.git.changed_files()
verification_line = (
f"`{verification.command}` -> rc={verification.returncode} ({verification.duration_s}s)"
if verification
else "brak weryfikacji"
)
description = (
f"""## Co i dlaczego
Automatyczna migracja `{request.package}` do wersji `{request.to_version}` wykonana przez pipeline
agentowy w trybie deterministycznym (reguły codemod z pakietu APM, bez udziału modelu językowego).
## Zakres zmian
"""
+ "\n".join(f"- `{path}`" for path in changed)
+ f"""
## Weryfikacja
{verification_line}
## Ryzyko i ograniczenia
- Tryb deterministyczny obejmuje wyłącznie przypadki opisane regułami codemod.
- Nietypowe użycia API mogły nie zostać wykryte - wymagana uwaga recenzenta.
## Ślad audytowy
- run_id: `{self.ctx.run_id}`
- kontekst APM: `{", ".join(self.ctx.apm.packages) or "local"}` (lock: `{self.ctx.apm.lockfile_hash}`)
- tryb: deterministyczny (bez LLM)
## Lista kontrolna dla recenzenta
- [ ] Diff nie zawiera zmian spoza zakresu
- [ ] Brak zmian w testach maskujących błąd
- [ ] Wersja zależności zgodna z zadaniem
"""
)
return MergeRequestDraft(
title=f"build(deps): {request.package} -> {request.to_version} wraz z migracją API",
description=description,
)
def build_brain(ctx: PipelineContext) -> Brain:
return RuleBrain(ctx) if ctx.mode == "offline" else LlmBrain(ctx)
+102
View File
@@ -0,0 +1,102 @@
"""Deterministyczny silnik reguł migracyjnych.
Reguły dostarczane w pakiecie APM obok notatki migracyjnej (`*.codemod.yaml`).
Uruchamiamy je przed agentem: każda linia zmigrowana regułą to linia, której model
nie musi dotykać - mniej tokenów, mniej ryzyka, w pełni powtarzalny wynik.
"""
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass, field
from pathlib import Path
import yaml
#: katalogi pomijane przez silnik reguł (artefakty, zależności, vendorowane SDK)
IGNORED_DIRS = {".git", "__pycache__", ".venv", "node_modules", "stubs", "vendor", "site-packages"}
@dataclass
class CodemodRule:
id: str
pattern: str
replacement: str
description: str = ""
multiline: bool = False
def compiled(self) -> re.Pattern[str]:
flags = re.MULTILINE if self.multiline else 0
return re.compile(self.pattern, flags)
@dataclass
class CodemodRuleset:
package: str
rules: list[CodemodRule]
file_glob: str = "*.py"
applies_to: str = ""
source: Path | None = None
@dataclass
class CodemodResult:
changed_files: list[str] = field(default_factory=list)
applied: dict[str, int] = field(default_factory=dict)
remaining_rules: list[str] = field(default_factory=list)
@property
def total_replacements(self) -> int:
return sum(self.applied.values())
def load_rulesets(skill_paths: Iterable[Path], package: str) -> list[CodemodRuleset]:
"""Znajduje zestawy reguł dla pakietu w katalogach `references/` skilli APM."""
rulesets: list[CodemodRuleset] = []
for skill_path in skill_paths:
for path in sorted((skill_path / "references").glob("*.codemod.yaml")):
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if str(data.get("package", "")).lower() != package.lower():
continue
rulesets.append(
CodemodRuleset(
package=str(data.get("package")),
file_glob=str(data.get("file_glob", "*.py")),
applies_to=str(data.get("applies_to", "")),
source=path,
rules=[
CodemodRule(
id=str(rule["id"]),
pattern=str(rule["pattern"]),
replacement=str(rule.get("replacement", "")),
description=str(rule.get("description", "")),
multiline=bool(rule.get("multiline", False)),
)
for rule in data.get("rules", [])
],
)
)
return rulesets
def apply_ruleset(root: Path, ruleset: CodemodRuleset, dry_run: bool = False) -> CodemodResult:
"""Stosuje reguły do plików repozytorium. `dry_run` służy do zbudowania planu."""
result = CodemodResult()
compiled = [(rule, rule.compiled()) for rule in ruleset.rules]
for path in sorted(Path(root).rglob(ruleset.file_glob)):
if any(part in IGNORED_DIRS for part in path.parts):
continue
original = path.read_text(encoding="utf-8")
updated = original
for rule, pattern in compiled:
updated, count = pattern.subn(rule.replacement, updated)
if count:
result.applied[rule.id] = result.applied.get(rule.id, 0) + count
if updated != original:
relative = path.relative_to(root).as_posix()
result.changed_files.append(relative)
if not dry_run:
path.write_text(updated, encoding="utf-8")
result.remaining_rules = [rule.id for rule in ruleset.rules if rule.id not in result.applied]
return result
+118
View File
@@ -0,0 +1,118 @@
"""Kontekst przebiegu - wszystko, co kroki pipeline'u współdzielą."""
from __future__ import annotations
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from ..adapters import EcosystemAdapter, detect_adapter
from ..apm.loader import ApmContext, load_apm_context
from ..config import Settings
from ..observability.audit import AuditLog
from ..schemas import ChangePlan, ChangeRequest, MergeRequestDraft, RepoProfile, ReviewVerdict, RunManifest
from ..tools import GitRepo, VerificationRunner, WorkspaceTools, build_tool_registry
IGNORED_ON_COPY = shutil.ignore_patterns(".git", "__pycache__", ".venv", "node_modules", ".pytest_cache", ".runs")
@dataclass
class PipelineContext:
request: ChangeRequest
settings: Settings
run_id: str
run_dir: Path
repo_root: Path
apm: ApmContext
audit: AuditLog
adapter: EcosystemAdapter
workspace: WorkspaceTools
verification: VerificationRunner
git: GitRepo
manifest: RunManifest
mode: str = "llm"
plan_only: bool = False
profile: RepoProfile | None = None
plan: ChangePlan | None = None
review: ReviewVerdict | None = None
merge_request: MergeRequestDraft | None = None
notes: list[str] = field(default_factory=list)
# ustawiane po zbudowaniu kontekstu (unika cyklicznego importu)
brain: object | None = None
@property
def tool_registry(self):
return build_tool_registry(self.workspace, self.verification)
def note(self, message: str) -> None:
self.notes.append(message)
self.audit.event("note", message=message)
def prepare_workspace(repo_path: Path, run_dir: Path, in_place: bool) -> Path:
"""Zwraca katalog, na którym pracuje sieć agentowa.
Domyślnie kopia - repozytorium źródłowe zostaje nietknięte także wtedy, gdy przebieg
zakończy się błędem w połowie edycji. W GitLab CI (`--in-place`) job i tak ma własny,
jednorazowy checkout, więc kopiowanie byłoby stratą czasu.
"""
repo_path = Path(repo_path).resolve()
if in_place:
return repo_path
target = Path(run_dir).resolve() / "workspace"
if target.exists():
shutil.rmtree(target)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(repo_path, target, ignore=IGNORED_ON_COPY)
return target
def build_context(
request: ChangeRequest,
settings: Settings,
run_id: str,
mode: str = "llm",
in_place: bool = False,
plan_only: bool = False,
verify_command: list[str] | None = None,
) -> PipelineContext:
run_dir = Path(settings.run_dir).resolve()
run_dir.mkdir(parents=True, exist_ok=True)
audit = AuditLog(run_dir=run_dir)
apm = load_apm_context(settings.apm_root)
audit.event("apm_context_loaded", **{k: str(v) for k, v in apm.summary().items()})
repo_root = prepare_workspace(Path(request.repo_path), run_dir, in_place)
adapter = detect_adapter(repo_root)
workspace = WorkspaceTools(repo_root, settings, audit)
verification = VerificationRunner(repo_root, adapter, settings, audit, command=verify_command)
git = GitRepo(repo_root)
git.init_if_needed()
manifest = RunManifest(
run_id=run_id,
mode="offline" if mode == "offline" else "llm",
request=request,
models=settings.models.as_dict() if mode != "offline" else {},
apm_context=apm.summary(),
)
return PipelineContext(
request=request,
settings=settings,
run_id=run_id,
run_dir=run_dir,
repo_root=repo_root,
apm=apm,
audit=audit,
adapter=adapter,
workspace=workspace,
verification=verification,
git=git,
manifest=manifest,
mode=mode,
plan_only=plan_only,
)
+86
View File
@@ -0,0 +1,86 @@
"""Deterministyczne ustalanie faktów o repozytorium.
Fakty, które da się ustalić kodem, ustalamy kodem. Model dostaje je jako dane wejściowe,
a nie jako zadanie do rozwiązania. To skraca przebieg i eliminuje całą klasę halucynacji
("pewnie testy uruchamia się przez tox").
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from ..adapters.base import EcosystemAdapter
from ..schemas import ChangeRequest, RepoProfile
def _grep(root: Path, pattern: str) -> list[str]:
proc = subprocess.run(
[
"grep",
"-rlE",
"--include=*.py",
"--include=*.java",
"--include=*.kt",
"--include=*.js",
"--include=*.ts",
"--exclude-dir=.git",
"--exclude-dir=__pycache__",
"--exclude-dir=.venv",
"--exclude-dir=node_modules",
pattern,
".",
],
cwd=root,
capture_output=True,
text=True,
timeout=60,
)
return sorted({line.lstrip("./") for line in proc.stdout.splitlines() if line})
def collect_facts(root: Path, adapter: EcosystemAdapter, request: ChangeRequest) -> RepoProfile:
package = request.package or ""
module = request.module_name or (adapter.module_name(package) if package else "")
profile = RepoProfile(
build_system=adapter.name,
dependency_files=[p.relative_to(root).as_posix() for p in adapter.dependency_files()],
module_name=module or None,
verify_command=" ".join(adapter.verify_command()),
)
if package:
profile.declared_version = adapter.read_declared_version(package)
if profile.declared_version is None:
profile.gaps.append(
f"Nie znaleziono deklaracji pakietu '{package}' w plikach zależności: "
+ ", ".join(profile.dependency_files)
)
if module:
usage_files = _grep(root, rf"(^|[^A-Za-z0-9_]){module}([^A-Za-z0-9_]|$)")
profile.usage_files = [f for f in usage_files if not f.startswith("stubs/")]
profile.blast_radius = (
"low" if len(profile.usage_files) <= 2 else "medium" if len(profile.usage_files) <= 8 else "high"
)
else:
profile.gaps.append("Nie ustalono nazwy modułu importu - podaj --module.")
return profile
def facts_as_markdown(profile: RepoProfile) -> str:
lines = [
"## Ustalone fakty o repozytorium (deterministycznie, nie zgadywane)",
f"- System budowania: {profile.build_system}",
f"- Pliki zależności: {', '.join(profile.dependency_files) or 'brak'}",
f"- Deklarowana wersja pakietu: {profile.declared_version or 'nie znaleziono'}",
f"- Moduł importu: {profile.module_name or 'nieustalony'}",
f"- Komenda weryfikacji: {profile.verify_command or 'nieustalona'}",
f"- Pliki z użyciem modułu ({len(profile.usage_files)}): {', '.join(profile.usage_files[:25]) or 'brak'}",
f"- Promień rażenia: {profile.blast_radius}",
]
if profile.gaps:
lines.append("- Luki wymagające uwagi: " + "; ".join(profile.gaps))
return "\n".join(lines)
+62
View File
@@ -0,0 +1,62 @@
"""Topologia sieci agentowej wyrażona jako agno Workflow."""
from __future__ import annotations
from agno.workflow import Condition, Loop, Step, Workflow
from .context import PipelineContext
from .steps import (
make_bump_dependency,
make_compose_merge_request,
make_implement,
make_intake,
make_plan,
make_recon,
make_review,
make_should_apply,
make_verification_green,
make_verify,
)
def build_workflow(ctx: PipelineContext) -> Workflow:
"""Buduje przepływ:
intake -> recon -> plan
|
+-- [warunek: jest co wdrażać i nie jest to plan-only]
bump-dependency
loop( implement -> verify ) # do zieleni lub limitu iteracji
review
compose-merge-request
Publikacja (branch, commit, push, MR) jest celowo POZA workflow - to jedyny krok
z efektem ubocznym poza katalogiem roboczym i podlega osobnej bramce w GitLab CI.
"""
apply_branch = [
Step(name="bump-dependency", executor=make_bump_dependency(ctx)),
Loop(
name="implement",
steps=[
Step(name="code", executor=make_implement(ctx)),
Step(name="verify", executor=make_verify(ctx)),
],
end_condition=make_verification_green(ctx),
max_iterations=ctx.settings.max_iterations,
),
Step(name="review", executor=make_review(ctx)),
Step(name="compose-merge-request", executor=make_compose_merge_request(ctx)),
]
return Workflow(
name="agentic-codemod",
description="Sieć agentowa modyfikująca kod na podstawie kontekstu z pakietów APM",
steps=[
Step(name="intake", executor=make_intake(ctx)),
Step(name="recon", executor=make_recon(ctx)),
Step(name="plan", executor=make_plan(ctx)),
Condition(name="apply-changes", evaluator=make_should_apply(ctx), steps=apply_branch),
],
store_events=False,
telemetry=False,
)
+111
View File
@@ -0,0 +1,111 @@
"""Uruchamianie przebiegu end-to-end."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from uuid import uuid4
from ..config import Settings
from ..integrations.gitlab import GitLabClient
from ..schemas import ChangeRequest, RunManifest
from .brains import build_brain
from .context import build_context
from .network import build_workflow
from .steps import dump_manifest
def new_run_id() -> str:
return f"{datetime.now(timezone.utc):%Y%m%d-%H%M%S}-{uuid4().hex[:6]}"
def _slug(value: str) -> str:
return re.sub(r"[^a-z0-9.-]+", "-", value.lower()).strip("-")
def run_pipeline(
request: ChangeRequest,
settings: Settings | None = None,
mode: str = "llm",
in_place: bool = False,
plan_only: bool = False,
publish: bool = False,
target_branch: str = "main",
verify_command: list[str] | None = None,
run_id: str | None = None,
) -> RunManifest:
settings = settings or Settings.from_env()
run_id = run_id or new_run_id()
ctx = build_context(
request=request,
settings=settings,
run_id=run_id,
mode=mode,
in_place=in_place,
plan_only=plan_only,
verify_command=verify_command,
)
ctx.brain = build_brain(ctx)
workflow = build_workflow(ctx)
try:
workflow.run(input=f"{request.task_type.value}:{request.package}->{request.to_version}")
except Exception as exc: # przebieg bez nadzoru: błąd musi wylądować w manifeście, nie tylko w logu
ctx.manifest.errors.append(f"{type(exc).__name__}: {exc}")
ctx.manifest.status = "failed"
ctx.audit.event("pipeline_error", error=str(exc)[:500])
dump_manifest(ctx)
raise
changed = ctx.git.changed_files()
last_verification = ctx.verification.results[-1] if ctx.verification.results else None
if plan_only:
ctx.manifest.status = "success"
elif not changed:
ctx.manifest.status = "no_changes"
elif last_verification is None or not last_verification.passed:
ctx.manifest.status = "failed"
elif ctx.review and ctx.review.verdict != "approve":
ctx.manifest.status = "blocked"
else:
ctx.manifest.status = "success"
if publish and ctx.manifest.status == "success" and ctx.merge_request:
_publish(ctx, target_branch)
ctx.manifest.finished_at = datetime.now(timezone.utc)
dump_manifest(ctx)
return ctx.manifest
def _publish(ctx, target_branch: str) -> None:
"""Gałąź + commit + MR. Jedyny krok z efektem poza katalogiem roboczym."""
request = ctx.request
branch = f"codemod/{_slug(request.package or 'change')}-{_slug(request.to_version or '')}-{ctx.run_id[-6:]}"
draft = ctx.merge_request
ctx.git.create_branch(branch)
ctx.git.commit_all(f"{draft.title}\n\nRun-Id: {ctx.run_id}\nGenerated-By: agentic-codemod")
try:
ctx.git.push(branch=branch)
except Exception as exc:
ctx.manifest.errors.append(f"push nieudany: {exc}")
ctx.audit.event("push_failed", error=str(exc)[:300])
return
client = GitLabClient(ctx.settings)
ref = client.create_merge_request(
source_branch=branch,
target_branch=target_branch,
title=draft.title,
description=draft.description,
)
ctx.audit.event("merge_request", created=ref.created, url=ref.web_url or "", detail=ref.detail)
if ref.web_url:
ctx.notes.append(f"MR: {ref.web_url}")
elif ref.detail:
ctx.manifest.errors.append(ref.detail)
if request.issue_ref and ref.web_url:
client.comment_on_issue(request.issue_ref, f"Pipeline agentowy przygotował zmianę: {ref.web_url}")
+176
View File
@@ -0,0 +1,176 @@
"""Kroki pipeline'u jako funkcje agno (`Step(executor=...)`).
Podział odpowiedzialności jest celowy:
- krok deterministyczny robi to, co można zrobić bez modelu (bump wersji, weryfikacja, git),
- krok "mózgowy" deleguje do `Brain` (agent agno albo reguły codemod).
Dzięki temu topologia przepływu jest czytelna i identyczna w obu trybach, a różnica
sprowadza się do implementacji mózgu.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from agno.workflow import StepInput, StepOutput
from ..schemas import ChangePlan
from .context import PipelineContext
Executor = Callable[[StepInput], StepOutput]
def make_intake(ctx: PipelineContext) -> Executor:
def intake(step_input: StepInput) -> StepOutput:
request = ctx.request
ctx.audit.event(
"intake",
package=request.package,
to_version=request.to_version,
repo=str(ctx.repo_root),
adapter=ctx.adapter.name,
mode=ctx.mode,
)
return StepOutput(
content=(
f"Zadanie: {request.task_type.value} | pakiet={request.package} "
f"-> {request.to_version} | ekosystem={ctx.adapter.name} | tryb={ctx.mode}"
),
success=True,
)
return intake
def make_recon(ctx: PipelineContext) -> Executor:
def recon(step_input: StepInput) -> StepOutput:
profile = ctx.brain.recon() # type: ignore[union-attr]
ctx.profile = profile
ctx.manifest.profile = profile
(ctx.run_dir / "profile.json").write_text(profile.model_dump_json(indent=2), encoding="utf-8")
return StepOutput(content=profile.model_dump_json(indent=2), success=True)
return recon
def make_plan(ctx: PipelineContext) -> Executor:
def plan(step_input: StepInput) -> StepOutput:
assert ctx.profile is not None
change_plan: ChangePlan = ctx.brain.plan(ctx.profile) # type: ignore[union-attr]
ctx.plan = change_plan
ctx.manifest.plan = change_plan
(ctx.run_dir / "plan.json").write_text(change_plan.model_dump_json(indent=2), encoding="utf-8")
for edit in change_plan.blocked_edits:
ctx.note(f"requires_human: {edit.path} - {edit.blocked_reason or edit.intent}")
return StepOutput(content=change_plan.model_dump_json(indent=2), success=True)
return plan
def make_bump_dependency(ctx: PipelineContext) -> Executor:
"""Deklarację wersji ustawia kod, nie model - to operacja w 100% deterministyczna."""
def bump(step_input: StepInput) -> StepOutput:
request = ctx.request
if not request.package or not request.to_version:
return StepOutput(content="Pominięto bump wersji (brak pakietu lub wersji docelowej).", success=True)
changed = ctx.adapter.set_declared_version(request.package, request.to_version)
relative = [p.relative_to(ctx.repo_root).as_posix() for p in changed]
for path in relative:
ctx.workspace.changed_files.add(path)
ctx.audit.record(
"bump_dependency",
{"package": request.package, "version": request.to_version},
ok=bool(relative),
detail=", ".join(relative) or "brak zmian",
)
if not relative:
ctx.note(f"Nie znaleziono deklaracji '{request.package}' do podbicia - sprawdź profil repozytorium.")
return StepOutput(content=f"Zaktualizowane pliki zależności: {', '.join(relative) or 'brak'}", success=True)
return bump
def make_implement(ctx: PipelineContext) -> Executor:
def implement(step_input: StepInput) -> StepOutput:
assert ctx.plan is not None
ctx.manifest.iterations += 1
result = ctx.brain.implement(ctx.plan) # type: ignore[union-attr]
return StepOutput(content=str(result), success=True)
return implement
def make_verify(ctx: PipelineContext) -> Executor:
def verify(step_input: StepInput) -> StepOutput:
result = ctx.verification.run()
ctx.manifest.verifications.append(result)
return StepOutput(
content=f"passed={result.passed} rc={result.returncode}\n{result.output_tail[-2000:]}",
success=result.passed,
)
return verify
def make_review(ctx: PipelineContext) -> Executor:
def review(step_input: StepInput) -> StepOutput:
diff = ctx.git.diff()
verdict = ctx.brain.review(diff) # type: ignore[union-attr]
ctx.review = verdict
ctx.manifest.review = verdict
(ctx.run_dir / "review.json").write_text(verdict.model_dump_json(indent=2), encoding="utf-8")
return StepOutput(content=verdict.model_dump_json(indent=2), success=verdict.verdict == "approve")
return review
def make_compose_merge_request(ctx: PipelineContext) -> Executor:
def compose(step_input: StepInput) -> StepOutput:
diff = ctx.git.diff()
last = ctx.verification.results[-1] if ctx.verification.results else None
draft = ctx.brain.compose_merge_request(diff, last) # type: ignore[union-attr]
ctx.merge_request = draft
ctx.manifest.merge_request = draft
(ctx.run_dir / "merge_request.md").write_text(f"# {draft.title}\n\n{draft.description}", encoding="utf-8")
(ctx.run_dir / "changes.patch").write_text(diff, encoding="utf-8")
return StepOutput(content=draft.title, success=True)
return compose
# --------------------------------------------------------------- predykaty
def make_should_apply(ctx: PipelineContext) -> Callable[[StepInput], bool]:
def should_apply(step_input: StepInput) -> bool:
if ctx.plan_only:
ctx.note("Tryb plan-only: pominięto modyfikację kodu.")
return False
if ctx.plan is None or not ctx.plan.actionable_edits:
ctx.note("Plan nie zawiera wykonalnych pozycji - nie ma czego wdrażać.")
return False
return True
return should_apply
def make_verification_green(ctx: PipelineContext) -> Callable[[list[StepOutput]], bool]:
def verification_green(outputs: list[StepOutput]) -> bool:
results = ctx.verification.results
if not results:
return False
if results[-1].passed:
return True
ctx.note(f"Iteracja {ctx.manifest.iterations}: weryfikacja czerwona, ponawiam.")
return False
return verification_green
def dump_manifest(ctx: PipelineContext) -> None:
ctx.manifest.changed_files = ctx.git.changed_files()
ctx.manifest.tool_calls = ctx.audit.entries
ctx.manifest.errors.extend(n for n in ctx.notes if n.startswith("requires_human"))
(ctx.run_dir / "run.json").write_text(
json.dumps(json.loads(ctx.manifest.model_dump_json()), ensure_ascii=False, indent=2), encoding="utf-8"
)
+55
View File
@@ -0,0 +1,55 @@
import os
import shutil
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
FIXTURE = REPO_ROOT / "examples" / "fixtures" / "acme-app"
sys.path.insert(0, str(REPO_ROOT / "src"))
sys.path.insert(0, str(REPO_ROOT / "llm")) # atrapa serwera OpenAI
@pytest.fixture(autouse=True)
def hermetyczne_srodowisko(monkeypatch):
"""Testy nie mogą zależeć od lokalnego .env developera.
Taskfile ładuje `.env`, więc bez tego wynik testów zmieniałby się w zależności
od tego, jaki endpoint albo budżet ktoś ustawił sobie na swojej maszynie.
"""
for key in list(os.environ):
if key.startswith("CODEMOD_"):
monkeypatch.delenv(key, raising=False)
@pytest.fixture()
def repo_root() -> Path:
return REPO_ROOT
@pytest.fixture()
def acme_app(tmp_path: Path) -> Path:
"""Kopia fixture'u - testy nigdy nie modyfikują wzorca w repozytorium."""
target = tmp_path / "acme-app"
shutil.copytree(FIXTURE, target, ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache"))
return target
@pytest.fixture()
def settings(tmp_path: Path):
from agentic_codemod.config import Settings
config = Settings.from_env()
config.apm_root = REPO_ROOT
config.run_dir = tmp_path / "run"
config.verify_timeout_s = 120
return config
@pytest.fixture()
def audit(tmp_path: Path):
from agentic_codemod.observability.audit import AuditLog
return AuditLog(run_dir=tmp_path / "audit")
+65
View File
@@ -0,0 +1,65 @@
"""Adaptery ekosystemów - wiedza deterministyczna, więc testowana punktowo."""
import json
import pytest
from agentic_codemod.adapters import (
EcosystemNotDetected,
MavenAdapter,
NodeNpmAdapter,
PythonPipAdapter,
detect_adapter,
)
def test_wykrywanie_ekosystemu_python(acme_app):
assert isinstance(detect_adapter(acme_app), PythonPipAdapter)
def test_brak_rozpoznanego_ekosystemu_to_twardy_blad(tmp_path):
with pytest.raises(EcosystemNotDetected):
detect_adapter(tmp_path)
def test_odczyt_i_podniesienie_wersji(acme_app):
adapter = PythonPipAdapter(acme_app)
assert adapter.read_declared_version("acme-sdk") == "==1.4.2"
changed = adapter.set_declared_version("acme-sdk", "2.1.0")
assert [p.name for p in changed] == ["pyproject.toml"]
assert adapter.read_declared_version("acme-sdk") == "==2.1.0"
def test_bump_nie_dotyka_prozy_zawierajacej_nazwe_pakietu(acme_app):
"""Regresja: wzorzec bez wymaganego operatora podmieniał też opis projektu."""
adapter = PythonPipAdapter(acme_app)
adapter.set_declared_version("acme-sdk", "2.1.0")
content = (acme_app / "pyproject.toml").read_text(encoding="utf-8")
assert "acme-sdk 1.x - fixture" in content
def test_bump_zachowuje_operator_porownania(tmp_path):
(tmp_path / "requirements.txt").write_text("acme-sdk >= 1.4\nhttpx==0.27.0\n", encoding="utf-8")
adapter = PythonPipAdapter(tmp_path)
adapter.set_declared_version("acme-sdk", "2.1.0")
assert (tmp_path / "requirements.txt").read_text(encoding="utf-8").startswith("acme-sdk >= 2.1.0")
def test_adapter_maven_podnosi_wersje_zaleznosci(tmp_path):
(tmp_path / "pom.xml").write_text(
"<project><dependencies><dependency>"
"<groupId>com.acme</groupId><artifactId>acme-sdk</artifactId><version>1.4.2</version>"
"</dependency></dependencies></project>",
encoding="utf-8",
)
adapter = MavenAdapter(tmp_path)
assert adapter.read_declared_version("com.acme:acme-sdk") == "1.4.2"
adapter.set_declared_version("com.acme:acme-sdk", "2.1.0")
assert adapter.read_declared_version("com.acme:acme-sdk") == "2.1.0"
def test_adapter_npm_zachowuje_prefiks_zakresu(tmp_path):
(tmp_path / "package.json").write_text(json.dumps({"dependencies": {"acme-sdk": "^1.4.2"}}), encoding="utf-8")
adapter = NodeNpmAdapter(tmp_path)
adapter.set_declared_version("acme-sdk", "2.1.0")
assert adapter.read_declared_version("acme-sdk") == "^2.1.0"
+102
View File
@@ -0,0 +1,102 @@
"""Kompilacja definicji APM do obiektów agno.
Test nie woła modelu - sprawdza to, co da się sprawdzić bez GPU: czy każda definicja
agenta z pakietu APM daje się zbudować, czy jej narzędzia istnieją, czy skille się walidują
i czy schemat wyjścia jest znany. To bramka na literówkę w `.agent.md`.
"""
from dataclasses import dataclass
import pytest
from agno.models.base import Model
from agentic_codemod.apm.agno_bridge import ToolResolutionError, build_agent, build_skills
from agentic_codemod.apm.loader import load_apm_context
from agentic_codemod.observability.audit import AuditLog
from agentic_codemod.tools import WorkspaceTools, build_tool_registry
from agentic_codemod.workflow.brains import SCHEMA_REGISTRY
@dataclass
class _StubModel(Model):
"""Model, który nigdy nie zostanie wywołany - potrzebny tylko po to,
by agno przyjęło konstrukcję agenta bez dostępu do endpointu.
"""
id: str = "stub"
name: str = "Stub"
provider: str = "Stub"
def invoke(self, *args, **kwargs):
raise NotImplementedError
async def ainvoke(self, *args, **kwargs):
raise NotImplementedError
def invoke_stream(self, *args, **kwargs):
raise NotImplementedError
async def ainvoke_stream(self, *args, **kwargs):
raise NotImplementedError
def _parse_provider_response(self, response, **kwargs):
raise NotImplementedError
def _parse_provider_response_delta(self, response):
raise NotImplementedError
class _StubModelFactory:
"""Zastępuje realny endpoint - budowa agenta nie może wymagać dostępnego LLM."""
def __init__(self):
self.calls = []
def for_profile(self, profile, temperature=None):
self.calls.append((profile, temperature))
return _StubModel(id=f"stub-{profile}")
@pytest.fixture()
def registry(acme_app, settings, tmp_path):
workspace = WorkspaceTools(acme_app, settings, AuditLog(run_dir=tmp_path / "audit"))
return build_tool_registry(workspace)
def test_kazda_definicja_agenta_daje_sie_zbudowac(repo_root, settings, registry):
ctx = load_apm_context(repo_root)
factory = _StubModelFactory()
registry = dict(registry)
registry["run_verification"] = lambda: "ok"
for name, definition in ctx.agents.items():
agent = build_agent(definition, ctx, settings, factory, registry, SCHEMA_REGISTRY)
assert agent.name == name
assert agent.instructions, "agent bez instrukcji to agent bez kontekstu"
assert agent.tool_call_limit and agent.tool_call_limit > 0
assert {profile for profile, _ in factory.calls} <= {"planner", "coder", "reviewer", "scribe"}
def test_schemat_wyjscia_jest_wiazany_z_rejestru(repo_root, settings, registry):
ctx = load_apm_context(repo_root)
registry = dict(registry)
registry["run_verification"] = lambda: "ok"
agent = build_agent(ctx.agent("planner"), ctx, settings, _StubModelFactory(), registry, SCHEMA_REGISTRY)
assert agent.output_schema is SCHEMA_REGISTRY["ChangePlan"]
def test_nieznane_narzedzie_w_definicji_konczy_sie_bledem(repo_root, settings, registry):
ctx = load_apm_context(repo_root)
definition = ctx.agent("coder")
definition.meta["tools"] = ["read_file", "rm_rf"]
with pytest.raises(ToolResolutionError, match="rm_rf"):
build_agent(definition, ctx, settings, _StubModelFactory(), registry, SCHEMA_REGISTRY)
def test_skille_agenta_sa_ladowane_z_katalogow_apm(repo_root):
ctx = load_apm_context(repo_root)
skills = build_skills(ctx, ctx.agent("coder").skill_names)
assert set(skills.get_skill_names()) == {"safe-code-edit", "sdk-version-upgrade"}
assert skills.get_skill("sdk-version-upgrade").references, "notatka migracyjna musi być widoczna dla agenta"
+54
View File
@@ -0,0 +1,54 @@
"""Kontekst APM jest konfiguracją sieci agentowej - musi się ładować i walidować."""
import pytest
from agentic_codemod.apm.loader import load_apm_context
from agentic_codemod.apm.primitives import parse_frontmatter
def test_kontekst_zawiera_wszystkie_role_agentow(repo_root):
ctx = load_apm_context(repo_root)
assert {"scout", "planner", "coder", "reviewer", "scribe"} <= set(ctx.agents)
assert {"repo-recon", "sdk-version-upgrade", "safe-code-edit", "merge-request-authoring"} <= set(ctx.skills)
assert {"security-guardrails", "python-conventions"} <= set(ctx.instructions)
def test_definicja_agenta_wskazuje_istniejace_skille_i_instrukcje(repo_root):
ctx = load_apm_context(repo_root)
for name, definition in ctx.agents.items():
assert ctx.skill_paths(definition.skill_names) or not definition.skill_names, name
assert ctx.instruction_bodies(definition.instruction_names) or not definition.instruction_names, name
def test_skille_sa_zgodne_ze_specyfikacja_agent_skills(repo_root):
"""agno waliduje katalogi skilli - niepoprawny skill musi wysadzić przebieg na starcie."""
from agno.skills import LocalSkills
ctx = load_apm_context(repo_root)
for ref in ctx.skills.values():
assert LocalSkills(str(ref.path), validate=True).load()
def test_prompt_renderuje_sie_z_wymaganymi_wejsciami(repo_root):
ctx = load_apm_context(repo_root)
prompt = ctx.prompt("sdk-upgrade")
rendered = prompt.render({"package": "acme-sdk", "to_version": "2.1.0", "repo": "/tmp/x"})
assert "acme-sdk" in rendered and "2.1.0" in rendered
assert "{{" not in rendered
def test_brak_wymaganego_wejscia_to_blad_konfiguracji_nie_zgadywanie(repo_root):
ctx = load_apm_context(repo_root)
with pytest.raises(ValueError, match="brak wymaganych wejść"):
ctx.prompt("sdk-upgrade").render({"package": "acme-sdk"})
def test_brakujacy_skill_konczy_sie_czytelnym_bledem(repo_root):
ctx = load_apm_context(repo_root)
with pytest.raises(KeyError, match="apm install"):
ctx.skill_paths(["nie-istnieje"])
def test_parsowanie_frontmattera_bez_naglowka():
meta, body = parse_frontmatter("zwykły tekst")
assert meta == {} and body == "zwykły tekst"
+21
View File
@@ -0,0 +1,21 @@
"""Ślad audytowy jest artefaktem CI - nie może wynosić sekretów."""
from agentic_codemod.observability.audit import redact
def test_redakcja_tokenow():
assert "glpat-" not in redact("token: glpat-ABCDEFGHIJKLMNOP")
assert "supersecret" not in redact('api_key="supersecret123"')
assert "***REDACTED***" in redact("Authorization: Bearer abcdefghijklmnop")
def test_redakcja_nie_niszczy_zwyklego_tekstu():
text = "Zmieniono plik src/acme_app/notifier.py, 9 podmian."
assert redact(text) == text
def test_log_zapisuje_wpisy_do_pliku(audit):
audit.record("read_file", {"path": "a.py"}, detail="ok")
audit.event("intake", package="acme-sdk")
assert (audit.run_dir / "trace.jsonl").read_text(encoding="utf-8").count("\n") == 2
assert audit.tool_call_count == 1
+49
View File
@@ -0,0 +1,49 @@
"""Reguły codemod z pakietu APM - deterministyczna część migracji."""
from agentic_codemod.apm.loader import load_apm_context
from agentic_codemod.workflow.codemod import apply_ruleset, load_rulesets
def _ruleset(repo_root):
ctx = load_apm_context(repo_root)
rulesets = load_rulesets([ref.path for ref in ctx.skills.values()], "acme-sdk")
assert rulesets, "pakiet APM musi dostarczać reguły dla acme-sdk"
return rulesets[0]
def test_reguly_sa_ladowane_z_referencji_skilla(repo_root):
ruleset = _ruleset(repo_root)
assert ruleset.package == "acme-sdk"
assert {rule.id for rule in ruleset.rules} >= {"import-client", "constructor", "send-message"}
def test_dry_run_nie_zmienia_plikow(repo_root, acme_app):
before = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
result = apply_ruleset(acme_app, _ruleset(repo_root), dry_run=True)
assert result.changed_files == ["src/acme_app/notifier.py"]
assert (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8") == before
def test_migracja_usuwa_stare_api(repo_root, acme_app):
apply_ruleset(acme_app, _ruleset(repo_root))
content = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
assert "AcmeClient(api_key=api_key, base_url=endpoint)" in content
assert "client.messages.create(recipient=" in content
assert "client.send(" not in content
assert ".close()" not in content
assert 'result["id"]' not in content
def test_reguly_nie_dotykaja_vendorowanego_sdk(repo_root, acme_app):
before = (acme_app / "stubs/acme/__init__.py").read_text(encoding="utf-8")
apply_ruleset(acme_app, _ruleset(repo_root))
assert (acme_app / "stubs/acme/__init__.py").read_text(encoding="utf-8") == before
def test_ponowne_uruchomienie_jest_idempotentne(repo_root, acme_app):
ruleset = _ruleset(repo_root)
apply_ruleset(acme_app, ruleset)
after_first = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
second = apply_ruleset(acme_app, ruleset)
assert second.changed_files == []
assert (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8") == after_first
+80
View File
@@ -0,0 +1,80 @@
"""Ścieżka agentowa (LlmBrain) przepuszczona przez atrapę serwera OpenAI.
Tryb offline sprawdza reguły; ten test sprawdza to, czego reguły nie dotykają:
budowę agentów z definicji APM, wywoływanie sandboxowanych narzędzi przez model
i parsowanie strukturalnych wyjść. Bez GPU, bez sieci, tak samo na Linuksie i macOS.
"""
from __future__ import annotations
import threading
import pytest
from mock_server import serve
from agentic_codemod.schemas import ChangeRequest, TaskType
from agentic_codemod.workflow.runner import run_pipeline
@pytest.fixture()
def mock_llm():
httpd = serve(host="127.0.0.1", port=0)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host, port = httpd.server_address[:2]
try:
yield f"http://{host}:{port}/v1"
finally:
httpd.shutdown()
thread.join(timeout=5)
@pytest.fixture()
def llm_settings(settings, mock_llm):
settings.provider = "openai_like"
settings.base_url = mock_llm
settings.api_key = "mock"
for role in ("planner", "coder", "reviewer", "scribe"):
setattr(settings.models, role, "mock/agentic-codemod")
return settings
@pytest.fixture()
def request_upgrade(acme_app):
return ChangeRequest(
task_type=TaskType.SDK_UPGRADE,
repo_path=str(acme_app),
package="acme-sdk",
module_name="acme",
from_version="1.4.2",
to_version="2.1.0",
)
def test_przebieg_z_agentami_konczy_sie_zielona_weryfikacja(request_upgrade, llm_settings, acme_app):
manifest = run_pipeline(request_upgrade, settings=llm_settings, mode="llm", in_place=True)
assert manifest.status == "success", manifest.errors
assert manifest.mode == "llm"
assert manifest.verifications and manifest.verifications[-1].passed
assert set(manifest.changed_files) == {"pyproject.toml", "src/acme_app/notifier.py"}
migrated = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
assert "AcmeClient(api_key=api_key, base_url=endpoint)" in migrated
assert "client.send(" not in migrated
def test_strukturalne_wyjscia_agentow_sa_parsowane(request_upgrade, llm_settings):
manifest = run_pipeline(request_upgrade, settings=llm_settings, mode="llm", in_place=True)
assert manifest.profile and manifest.profile.module_name == "acme"
assert manifest.plan and manifest.plan.actionable_edits[0].path == "src/acme_app/notifier.py"
assert manifest.review and manifest.review.verdict == "approve"
assert manifest.merge_request and manifest.merge_request.title.startswith("build(deps):")
def test_agent_uzywa_sandboxowanych_narzedzi(request_upgrade, llm_settings):
manifest = run_pipeline(request_upgrade, settings=llm_settings, mode="llm", in_place=True)
used = {entry["tool"] for entry in manifest.tool_calls}
assert {"read_file", "replace_in_file", "run_verification"} <= used
+91
View File
@@ -0,0 +1,91 @@
"""Przebieg end-to-end bez modelu językowego.
To jest smoke test całego pipeline'u: kontekst APM, adaptery, sandbox, workflow agno,
weryfikacja i artefakty audytowe. Uruchamialny na runnerze bez GPU i bez internetu.
"""
import json
import pytest
from agentic_codemod.schemas import ChangeRequest, TaskType
from agentic_codemod.workflow.runner import run_pipeline
@pytest.fixture()
def request_upgrade(acme_app):
return ChangeRequest(
task_type=TaskType.SDK_UPGRADE,
repo_path=str(acme_app),
package="acme-sdk",
module_name="acme",
from_version="1.4.2",
to_version="2.1.0",
)
def test_fixture_jest_czerwony_przed_migracja(acme_app):
import subprocess
proc = subprocess.run(["python3", "-m", "pytest", "-q"], cwd=acme_app, capture_output=True, text=True)
assert proc.returncode != 0, "fixture musi być czerwony, inaczej test e2e niczego nie dowodzi"
def test_przebieg_offline_konczy_sie_zielona_weryfikacja(request_upgrade, settings, acme_app):
manifest = run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=True)
assert manifest.status == "success"
assert manifest.verifications and manifest.verifications[-1].passed
assert set(manifest.changed_files) == {"pyproject.toml", "src/acme_app/notifier.py"}
assert manifest.review and manifest.review.verdict == "approve"
assert "acme-sdk==2.1.0" in (acme_app / "pyproject.toml").read_text(encoding="utf-8")
def test_przebieg_zapisuje_komplet_artefaktow_audytowych(request_upgrade, settings):
run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=True)
run_dir = settings.run_dir
for name in (
"run.json",
"plan.json",
"profile.json",
"review.json",
"merge_request.md",
"changes.patch",
"trace.jsonl",
):
assert (run_dir / name).exists(), f"brak artefaktu {name}"
manifest = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
assert manifest["apm_context"]["skills"], "manifest musi odnotować, z jakiego kontekstu korzystał przebieg"
assert manifest["run_id"] and manifest["mode"] == "offline"
def test_tryb_plan_only_nie_zmienia_kodu(request_upgrade, settings, acme_app):
before = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
manifest = run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=True, plan_only=True)
assert manifest.status == "success"
assert manifest.plan is not None and manifest.plan.edits
assert manifest.changed_files == []
assert (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8") == before
def test_praca_na_kopii_nie_rusza_katalogu_zrodlowego(request_upgrade, settings, acme_app):
before = (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8")
manifest = run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=False)
assert manifest.status == "success"
assert (acme_app / "src/acme_app/notifier.py").read_text(encoding="utf-8") == before
def test_recenzja_blokuje_gdy_weryfikacja_czerwona(request_upgrade, settings, acme_app):
"""Sabotujemy testy fixture'u: pipeline nie może zaraportować sukcesu."""
(acme_app / "tests" / "test_notifier.py").write_text(
"def test_zawsze_czerwony():\n assert False\n", encoding="utf-8"
)
settings.max_iterations = 1
manifest = run_pipeline(request_upgrade, settings=settings, mode="offline", in_place=True)
assert manifest.status == "failed"
assert not manifest.verifications[-1].passed
+58
View File
@@ -0,0 +1,58 @@
"""Sandbox narzędzi plikowych to ostatnia linia obrony - testujemy go jak zabezpieczenie, nie jak feature."""
import pytest
from agentic_codemod.tools import WorkspaceTools
from agentic_codemod.tools.workspace import WorkspaceError
@pytest.fixture()
def workspace(acme_app, settings, audit):
return WorkspaceTools(acme_app, settings, audit)
def test_odczyt_pliku_zwraca_numerowane_linie(workspace):
content = workspace.read_file("src/acme_app/notifier.py")
assert " 1| " in content
def test_wyjscie_poza_repozytorium_jest_zabronione(workspace):
with pytest.raises(WorkspaceError, match="poza repozytorium"):
workspace.read_file("../../etc/passwd")
@pytest.mark.parametrize("path", [".gitlab-ci.yml", ".git/config", "deploy/secret.env", "app.pem"])
def test_sciezki_objete_zakazem_sa_blokowane(workspace, path):
with pytest.raises(WorkspaceError, match="zakaz|poza repozytorium"):
workspace.write_file(path, "cokolwiek")
def test_replace_wymaga_jednoznacznego_fragmentu(workspace):
with pytest.raises(WorkspaceError, match="występuje 2 razy"):
workspace.replace_in_file(
"src/acme_app/notifier.py",
" client = Client(api_key=api_key, endpoint=endpoint)\n",
" client = AcmeClient()\n",
)
def test_replace_bez_dopasowania_daje_wskazowke_nie_wyjatek_techniczny(workspace):
with pytest.raises(WorkspaceError, match="Odczytaj plik ponownie"):
workspace.replace_in_file("src/acme_app/notifier.py", "czegoś takiego tu nie ma", "x")
def test_udana_edycja_jest_rejestrowana(workspace):
workspace.replace_in_file("src/acme_app/notifier.py", "from acme import Client", "from acme import AcmeClient")
assert "src/acme_app/notifier.py" in workspace.changed_files
assert any(entry["tool"] == "replace_in_file" and entry["ok"] for entry in workspace.audit.entries)
def test_budzet_zmienionych_plikow_jest_egzekwowany(workspace, settings):
settings.max_files_changed = 1
workspace.write_file("a.py", "x = 1\n")
with pytest.raises(WorkspaceError, match="budżet"):
workspace.write_file("b.py", "y = 2\n")
def test_wyszukiwanie_zwraca_dopasowania(workspace):
assert "notifier.py" in workspace.search_repo("from acme import", "*.py")