commit 142f5f575905396f334a2e22516b5de22aac019f Author: Aleksander Cynarski Date: Sat Aug 29 13:17:59 2026 +0200 feat: initial commit diff --git a/.apm/agents/coder.agent.md b/.apm/agents/coder.agent.md new file mode 100644 index 0000000..5dd615f --- /dev/null +++ b/.apm/agents/coder.agent.md @@ -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". diff --git a/.apm/agents/planner.agent.md b/.apm/agents/planner.agent.md new file mode 100644 index 0000000..9b0da11 --- /dev/null +++ b/.apm/agents/planner.agent.md @@ -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. diff --git a/.apm/agents/reviewer.agent.md b/.apm/agents/reviewer.agent.md new file mode 100644 index 0000000..2f43469 --- /dev/null +++ b/.apm/agents/reviewer.agent.md @@ -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. diff --git a/.apm/agents/scout.agent.md b/.apm/agents/scout.agent.md new file mode 100644 index 0000000..f1b0cae --- /dev/null +++ b/.apm/agents/scout.agent.md @@ -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`. diff --git a/.apm/agents/scribe.agent.md b/.apm/agents/scribe.agent.md new file mode 100644 index 0000000..7209e9d --- /dev/null +++ b/.apm/agents/scribe.agent.md @@ -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. diff --git a/.apm/context/run-contract.md b/.apm/context/run-contract.md new file mode 100644 index 0000000..d03685c --- /dev/null +++ b/.apm/context/run-contract.md @@ -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. diff --git a/.apm/instructions/python.instructions.md b/.apm/instructions/python.instructions.md new file mode 100644 index 0000000..bf4928b --- /dev/null +++ b/.apm/instructions/python.instructions.md @@ -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ą. diff --git a/.apm/instructions/security.instructions.md b/.apm/instructions/security.instructions.md new file mode 100644 index 0000000..223a475 --- /dev/null +++ b/.apm/instructions/security.instructions.md @@ -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. diff --git a/.apm/prompts/dependency-audit.prompt.md b/.apm/prompts/dependency-audit.prompt.md new file mode 100644 index 0000000..e5888c5 --- /dev/null +++ b/.apm/prompts/dependency-audit.prompt.md @@ -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. diff --git a/.apm/prompts/sdk-upgrade.prompt.md b/.apm/prompts/sdk-upgrade.prompt.md new file mode 100644 index 0000000..d927297 --- /dev/null +++ b/.apm/prompts/sdk-upgrade.prompt.md @@ -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. diff --git a/.apm/skills/merge-request-authoring/SKILL.md b/.apm/skills/merge-request-authoring/SKILL.md new file mode 100644 index 0000000..43afbdd --- /dev/null +++ b/.apm/skills/merge-request-authoring/SKILL.md @@ -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. diff --git a/.apm/skills/repo-recon/SKILL.md b/.apm/skills/repo-recon/SKILL.md new file mode 100644 index 0000000..c1488b5 --- /dev/null +++ b/.apm/skills/repo-recon/SKILL.md @@ -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. diff --git a/.apm/skills/safe-code-edit/SKILL.md b/.apm/skills/safe-code-edit/SKILL.md new file mode 100644 index 0000000..a711c44 --- /dev/null +++ b/.apm/skills/safe-code-edit/SKILL.md @@ -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. diff --git a/.apm/skills/sdk-version-upgrade/SKILL.md b/.apm/skills/sdk-version-upgrade/SKILL.md new file mode 100644 index 0000000..d3dee25 --- /dev/null +++ b/.apm/skills/sdk-version-upgrade/SKILL.md @@ -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. diff --git a/.apm/skills/sdk-version-upgrade/references/acme-sdk-2.x-migration.codemod.yaml b/.apm/skills/sdk-version-upgrade/references/acme-sdk-2.x-migration.codemod.yaml new file mode 100644 index 0000000..f8cb720 --- /dev/null +++ b/.apm/skills/sdk-version-upgrade/references/acme-sdk-2.x-migration.codemod.yaml @@ -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[^,]+),\s*endpoint=(?P[^)]+)\)' + replacement: 'AcmeClient(api_key=\g, base_url=\g)' + - id: send-message + description: "send() -> messages.create() z nowymi nazwami parametrów" + pattern: '\.send\(\s*to=(?P[^,]+),\s*body=(?P[^)]+)\)' + replacement: '.messages.create(recipient=\g, content=\g)' + - 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\b[a-z_][a-z0-9_]*)\[[''"]id[''"]\]' + replacement: '\g.id' diff --git a/.apm/skills/sdk-version-upgrade/references/acme-sdk-2.x-migration.md b/.apm/skills/sdk-version-upgrade/references/acme-sdk-2.x-migration.md new file mode 100644 index 0000000..1f039fc --- /dev/null +++ b/.apm/skills/sdk-version-upgrade/references/acme-sdk-2.x-migration.md @@ -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. diff --git a/.apm/skills/sdk-version-upgrade/scripts/find_usages.sh b/.apm/skills/sdk-version-upgrade/scripts/find_usages.sh new file mode 100755 index 0000000..bbfbed6 --- /dev/null +++ b/.apm/skills/sdk-version-upgrade/scripts/find_usages.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Znajduje użycia modułu w repozytorium wraz z kontekstem. +# Użycie: find_usages.sh +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}" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..721513e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c7a2ff --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..eec810b --- /dev/null +++ b/.gitlab-ci.yml @@ -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: "." diff --git a/.gitlab/ci/agentic-codemod.template.yml b/.gitlab/ci/agentic-codemod.template.yml new file mode 100644 index 0000000..08f7027 --- /dev/null +++ b/.gitlab/ci/agentic-codemod.template.yml @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..975fd00 --- /dev/null +++ b/README.md @@ -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. diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..df7968e --- /dev/null +++ b/Taskfile.yml @@ -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 diff --git a/apm-policy.yml b/apm-policy.yml new file mode 100644 index 0000000..774293f --- /dev/null +++ b/apm-policy.yml @@ -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 diff --git a/apm.yml b/apm.yml new file mode 100644 index 0000000..bab539a --- /dev/null +++ b/apm.yml @@ -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 ` - 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" diff --git a/ci/Dockerfile b/ci/Dockerfile new file mode 100644 index 0000000..2075315 --- /dev/null +++ b/ci/Dockerfile @@ -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"] diff --git a/ci/entrypoint.sh b/ci/entrypoint.sh new file mode 100755 index 0000000..c24d036 --- /dev/null +++ b/ci/entrypoint.sh @@ -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}" \ + "$@" diff --git a/docs/adr/0001-apm-jako-lancuch-dostaw-kontekstu.md b/docs/adr/0001-apm-jako-lancuch-dostaw-kontekstu.md new file mode 100644 index 0000000..631153b --- /dev/null +++ b/docs/adr/0001-apm-jako-lancuch-dostaw-kontekstu.md @@ -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). diff --git a/docs/adr/0002-podzial-na-krok-deterministyczny-i-model.md b/docs/adr/0002-podzial-na-krok-deterministyczny-i-model.md new file mode 100644 index 0000000..907c5d6 --- /dev/null +++ b/docs/adr/0002-podzial-na-krok-deterministyczny-i-model.md @@ -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. diff --git a/docs/adr/0003-runtime-w-efemerycznym-jobie-gitlab-ci.md b/docs/adr/0003-runtime-w-efemerycznym-jobie-gitlab-ci.md new file mode 100644 index 0000000..60f1a2b --- /dev/null +++ b/docs/adr/0003-runtime-w-efemerycznym-jobie-gitlab-ci.md @@ -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. diff --git a/docs/adr/0004-guardraile-w-dwoch-warstwach.md b/docs/adr/0004-guardraile-w-dwoch-warstwach.md new file mode 100644 index 0000000..da643c0 --- /dev/null +++ b/docs/adr/0004-guardraile-w-dwoch-warstwach.md @@ -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. diff --git a/docs/adr/0005-backend-llm-per-platforma.md b/docs/adr/0005-backend-llm-per-platforma.md new file mode 100644 index 0000000..520e0a0 --- /dev/null +++ b/docs/adr/0005-backend-llm-per-platforma.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7196512 --- /dev/null +++ b/docs/architecture.md @@ -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ć. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..06362e0 --- /dev/null +++ b/docs/runbook.md @@ -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/--migration.md`. +2. Reguły maszynowe: `references/--migration.codemod.yaml`. +3. Publikacja jako pakiet APM z tagiem, dopisanie do `apm.yml` repozytoriów docelowych. + +Nic w Pythonie się nie zmienia. diff --git a/examples/fixtures/acme-app/README.md b/examples/fixtures/acme-app/README.md new file mode 100644 index 0000000..6602efd --- /dev/null +++ b/examples/fixtures/acme-app/README.md @@ -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. diff --git a/examples/fixtures/acme-app/conftest.py b/examples/fixtures/acme-app/conftest.py new file mode 100644 index 0000000..55d8812 --- /dev/null +++ b/examples/fixtures/acme-app/conftest.py @@ -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)) diff --git a/examples/fixtures/acme-app/pyproject.toml b/examples/fixtures/acme-app/pyproject.toml new file mode 100644 index 0000000..9a72cce --- /dev/null +++ b/examples/fixtures/acme-app/pyproject.toml @@ -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"] diff --git a/examples/fixtures/acme-app/src/acme_app/__init__.py b/examples/fixtures/acme-app/src/acme_app/__init__.py new file mode 100644 index 0000000..8ffadab --- /dev/null +++ b/examples/fixtures/acme-app/src/acme_app/__init__.py @@ -0,0 +1,3 @@ +"""Przykładowa aplikacja korzystająca z acme-sdk.""" + +__all__ = ["notifier"] diff --git a/examples/fixtures/acme-app/src/acme_app/notifier.py b/examples/fixtures/acme-app/src/acme_app/notifier.py new file mode 100644 index 0000000..0eed562 --- /dev/null +++ b/examples/fixtures/acme-app/src/acme_app/notifier.py @@ -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"] diff --git a/examples/fixtures/acme-app/stubs/acme/__init__.py b/examples/fixtures/acme-app/stubs/acme/__init__.py new file mode 100644 index 0000000..e1c3445 --- /dev/null +++ b/examples/fixtures/acme-app/stubs/acme/__init__.py @@ -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__"] diff --git a/examples/fixtures/acme-app/stubs/acme/errors.py b/examples/fixtures/acme-app/stubs/acme/errors.py new file mode 100644 index 0000000..7e64b95 --- /dev/null +++ b/examples/fixtures/acme-app/stubs/acme/errors.py @@ -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ę.""" diff --git a/examples/fixtures/acme-app/tests/test_notifier.py b/examples/fixtures/acme-app/tests/test_notifier.py new file mode 100644 index 0000000..0255252 --- /dev/null +++ b/examples/fixtures/acme-app/tests/test_notifier.py @@ -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 diff --git a/llm/docker-compose.yml b/llm/docker-compose.yml new file mode 100644 index 0000000..d5bd198 --- /dev/null +++ b/llm/docker-compose.yml @@ -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: diff --git a/llm/mock_server.py b/llm/mock_server.py new file mode 100644 index 0000000..e0feb3b --- /dev/null +++ b/llm/mock_server.py @@ -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 są 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() diff --git a/llm/models.yml b/llm/models.yml new file mode 100644 index 0000000..e41a9c3 --- /dev/null +++ b/llm/models.yml @@ -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 diff --git a/llm/scripts/backend.py b/llm/scripts/backend.py new file mode 100755 index 0000000..edeb427 --- /dev/null +++ b/llm/scripts/backend.py @@ -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()) diff --git a/llm/scripts/daemonize.py b/llm/scripts/daemonize.py new file mode 100755 index 0000000..d0cd02a --- /dev/null +++ b/llm/scripts/daemonize.py @@ -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()) diff --git a/llm/scripts/logs.sh b/llm/scripts/logs.sh new file mode 100755 index 0000000..c8919bc --- /dev/null +++ b/llm/scripts/logs.sh @@ -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 diff --git a/llm/scripts/vllm-metal.sh b/llm/scripts/vllm-metal.sh new file mode 100755 index 0000000..1ecf831 --- /dev/null +++ b/llm/scripts/vllm-metal.sh @@ -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 | down | 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 |down }" + ;; +esac diff --git a/llm/scripts/wait-for-endpoint.sh b/llm/scripts/wait-for-endpoint.sh new file mode 100755 index 0000000..d78d223 --- /dev/null +++ b/llm/scripts/wait-for-endpoint.sh @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..392531c --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/agentic_codemod/__init__.py b/src/agentic_codemod/__init__.py new file mode 100644 index 0000000..75e1bde --- /dev/null +++ b/src/agentic_codemod/__init__.py @@ -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" diff --git a/src/agentic_codemod/__main__.py b/src/agentic_codemod/__main__.py new file mode 100644 index 0000000..d5cf0fd --- /dev/null +++ b/src/agentic_codemod/__main__.py @@ -0,0 +1,4 @@ +from .cli import app + +if __name__ == "__main__": + app() diff --git a/src/agentic_codemod/adapters/__init__.py b/src/agentic_codemod/adapters/__init__.py new file mode 100644 index 0000000..19b42bc --- /dev/null +++ b/src/agentic_codemod/adapters/__init__.py @@ -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", +] diff --git a/src/agentic_codemod/adapters/base.py b/src/agentic_codemod/adapters/base.py new file mode 100644 index 0000000..ed482fa --- /dev/null +++ b/src/agentic_codemod/adapters/base.py @@ -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 diff --git a/src/agentic_codemod/adapters/maven.py b/src/agentic_codemod/adapters/maven.py new file mode 100644 index 0000000..864ade0 --- /dev/null +++ b/src/agentic_codemod/adapters/maven.py @@ -0,0 +1,62 @@ +"""Ekosystem Java/Maven - wersje trzymane w properties lub w . + +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"(\s*{re.escape(group)}\s*\s*" + rf"\s*{re.escape(artifact)}\s*\s*" + rf")(?P[^<]+)()", + 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"] diff --git a/src/agentic_codemod/adapters/node_npm.py b/src/agentic_codemod/adapters/node_npm.py new file mode 100644 index 0000000..e634237 --- /dev/null +++ b/src/agentic_codemod/adapters/node_npm.py @@ -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"] diff --git a/src/agentic_codemod/adapters/python_pip.py b/src/agentic_codemod/adapters/python_pip.py new file mode 100644 index 0000000..4383679 --- /dev/null +++ b/src/agentic_codemod/adapters/python_pip.py @@ -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\b{name}\b)(?P\s*)(?P==|>=|<=|~=|!=|>|<)(?P\s*)(?P[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"] diff --git a/src/agentic_codemod/apm/__init__.py b/src/agentic_codemod/apm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentic_codemod/apm/agno_bridge.py b/src/agentic_codemod/apm/agno_bridge.py new file mode 100644 index 0000000..74c79bd --- /dev/null +++ b/src/agentic_codemod/apm/agno_bridge.py @@ -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, + ) diff --git a/src/agentic_codemod/apm/loader.py b/src/agentic_codemod/apm/loader.py new file mode 100644 index 0000000..e75f361 --- /dev/null +++ b/src/agentic_codemod/apm/loader.py @@ -0,0 +1,137 @@ +"""Odkrywanie kontekstu agentowego dostarczonego przez APM. + +APM instaluje zależności do `apm_modules//`, 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 diff --git a/src/agentic_codemod/apm/primitives.py b/src/agentic_codemod/apm/primitives.py new file mode 100644 index 0000000..16da285 --- /dev/null +++ b/src/agentic_codemod/apm/primitives.py @@ -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" diff --git a/src/agentic_codemod/cli.py b/src/agentic_codemod/cli.py new file mode 100644 index 0000000..bb210f4 --- /dev/null +++ b/src/agentic_codemod/cli.py @@ -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()) diff --git a/src/agentic_codemod/config.py b/src/agentic_codemod/config.py new file mode 100644 index 0000000..1c7fb75 --- /dev/null +++ b/src/agentic_codemod/config.py @@ -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() diff --git a/src/agentic_codemod/integrations/__init__.py b/src/agentic_codemod/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentic_codemod/integrations/gitlab.py b/src/agentic_codemod/integrations/gitlab.py new file mode 100644 index 0000000..1ac48c7 --- /dev/null +++ b/src/agentic_codemod/integrations/gitlab.py @@ -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 diff --git a/src/agentic_codemod/llm.py b/src/agentic_codemod/llm.py new file mode 100644 index 0000000..546963f --- /dev/null +++ b/src/agentic_codemod/llm.py @@ -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.") diff --git a/src/agentic_codemod/observability/__init__.py b/src/agentic_codemod/observability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentic_codemod/observability/audit.py b/src/agentic_codemod/observability/audit.py new file mode 100644 index 0000000..de1ed47 --- /dev/null +++ b/src/agentic_codemod/observability/audit.py @@ -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:")) diff --git a/src/agentic_codemod/schemas.py b/src/agentic_codemod/schemas.py new file mode 100644 index 0000000..8ea3965 --- /dev/null +++ b/src/agentic_codemod/schemas.py @@ -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 są 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) diff --git a/src/agentic_codemod/tools/__init__.py b/src/agentic_codemod/tools/__init__.py new file mode 100644 index 0000000..96f8330 --- /dev/null +++ b/src/agentic_codemod/tools/__init__.py @@ -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"] diff --git a/src/agentic_codemod/tools/vcs.py b/src/agentic_codemod/tools/vcs.py new file mode 100644 index 0000000..485f20c --- /dev/null +++ b/src/agentic_codemod/tools/vcs.py @@ -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 są 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 ") -> 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) diff --git a/src/agentic_codemod/tools/verification.py b/src/agentic_codemod/tools/verification.py new file mode 100644 index 0000000..b9c0aab --- /dev/null +++ b/src/agentic_codemod/tools/verification.py @@ -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}" diff --git a/src/agentic_codemod/tools/workspace.py b/src/agentic_codemod/tools/workspace.py new file mode 100644 index 0000000..b7a5cbe --- /dev/null +++ b/src/agentic_codemod/tools/workspace.py @@ -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 są 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] diff --git a/src/agentic_codemod/workflow/__init__.py b/src/agentic_codemod/workflow/__init__.py new file mode 100644 index 0000000..f464816 --- /dev/null +++ b/src/agentic_codemod/workflow/__init__.py @@ -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"] diff --git a/src/agentic_codemod/workflow/brains.py b/src/agentic_codemod/workflow/brains.py new file mode 100644 index 0000000..23ac769 --- /dev/null +++ b/src/agentic_codemod/workflow/brains.py @@ -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) diff --git a/src/agentic_codemod/workflow/codemod.py b/src/agentic_codemod/workflow/codemod.py new file mode 100644 index 0000000..d0c998f --- /dev/null +++ b/src/agentic_codemod/workflow/codemod.py @@ -0,0 +1,102 @@ +"""Deterministyczny silnik reguł migracyjnych. + +Reguły są 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 diff --git a/src/agentic_codemod/workflow/context.py b/src/agentic_codemod/workflow/context.py new file mode 100644 index 0000000..8159e0b --- /dev/null +++ b/src/agentic_codemod/workflow/context.py @@ -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, + ) diff --git a/src/agentic_codemod/workflow/facts.py b/src/agentic_codemod/workflow/facts.py new file mode 100644 index 0000000..e4cfc59 --- /dev/null +++ b/src/agentic_codemod/workflow/facts.py @@ -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) diff --git a/src/agentic_codemod/workflow/network.py b/src/agentic_codemod/workflow/network.py new file mode 100644 index 0000000..bce93b7 --- /dev/null +++ b/src/agentic_codemod/workflow/network.py @@ -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, + ) diff --git a/src/agentic_codemod/workflow/runner.py b/src/agentic_codemod/workflow/runner.py new file mode 100644 index 0000000..e421e6c --- /dev/null +++ b/src/agentic_codemod/workflow/runner.py @@ -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}") diff --git a/src/agentic_codemod/workflow/steps.py b/src/agentic_codemod/workflow/steps.py new file mode 100644 index 0000000..30e1665 --- /dev/null +++ b/src/agentic_codemod/workflow/steps.py @@ -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" + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..947fa1f --- /dev/null +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..7fc75d5 --- /dev/null +++ b/tests/test_adapters.py @@ -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( + "" + "com.acmeacme-sdk1.4.2" + "", + 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" diff --git a/tests/test_agno_bridge.py b/tests/test_agno_bridge.py new file mode 100644 index 0000000..777410e --- /dev/null +++ b/tests/test_agno_bridge.py @@ -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" diff --git a/tests/test_apm_context.py b/tests/test_apm_context.py new file mode 100644 index 0000000..a79d43c --- /dev/null +++ b/tests/test_apm_context.py @@ -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" diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..6124560 --- /dev/null +++ b/tests/test_audit.py @@ -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 diff --git a/tests/test_codemod.py b/tests/test_codemod.py new file mode 100644 index 0000000..ae5cc9e --- /dev/null +++ b/tests/test_codemod.py @@ -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 diff --git a/tests/test_llm_path.py b/tests/test_llm_path.py new file mode 100644 index 0000000..e825ada --- /dev/null +++ b/tests/test_llm_path.py @@ -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 diff --git a/tests/test_pipeline_offline.py b/tests/test_pipeline_offline.py new file mode 100644 index 0000000..89f0ca4 --- /dev/null +++ b/tests/test_pipeline_offline.py @@ -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 diff --git a/tests/test_workspace_tools.py b/tests/test_workspace_tools.py new file mode 100644 index 0000000..965fc27 --- /dev/null +++ b/tests/test_workspace_tools.py @@ -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")