feat: initial commit
This commit is contained in:
@@ -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".
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: python-conventions
|
||||||
|
description: Konwencje kodu Python obowiązujące przy modyfikacjach plików *.py
|
||||||
|
applyTo: "**/*.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Konwencje Python
|
||||||
|
|
||||||
|
- Zachowuj istniejący styl pliku (cudzysłowy, długość linii, układ importów). Nie uruchamiaj
|
||||||
|
formatera na całym pliku.
|
||||||
|
- Type hints obowiązkowe w nowym i modyfikowanym kodzie publicznym.
|
||||||
|
- Preferuj menedżery kontekstu (`with`) dla zasobów zamiast ręcznego `close()`.
|
||||||
|
- Nie zmieniaj publicznych sygnatur funkcji bez odnotowania tego w planie jako `breaking`.
|
||||||
|
- Import stdlib > third-party > lokalne, rozdzielone pustą linią.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: security-guardrails
|
||||||
|
description: Nienegocjowalne zasady bezpieczeństwa dla każdej zmiany kodu wykonanej przez agenta
|
||||||
|
applyTo: "**/*"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Guardraile bezpieczeństwa
|
||||||
|
|
||||||
|
1. **Nigdy nie modyfikuj plików pipeline'u ani konfiguracji dostępu.** Zabronione ścieżki:
|
||||||
|
`.gitlab-ci.yml`, `.gitlab/**`, `.github/workflows/**`, `Dockerfile*`, `**/Chart.yaml`,
|
||||||
|
`**/*secret*`, `**/*.pem`, `**/*.key`, `.env*`. Jeśli zmiana wymaga dotknięcia tych plików -
|
||||||
|
zgłoś to w planie jako `requires_human` i nie edytuj.
|
||||||
|
2. **Nigdy nie zapisuj sekretów w kodzie.** Wartości tokenów, haseł i kluczy zawsze przez zmienne
|
||||||
|
środowiskowe. Jeśli w kodzie znajdziesz sekret - nie kopiuj go i nie cytuj w opisie MR;
|
||||||
|
zgłoś jako `finding` typu `secret_exposure`.
|
||||||
|
3. **Nie rozszerzaj zakresu zmiany.** Wolno zmieniać wyłącznie to, co wynika z zatwierdzonego planu.
|
||||||
|
Refaktory "przy okazji", formatowanie całych plików i porządkowanie importów w niezwiązanych
|
||||||
|
modułach są zabronione - psują audytowalność diffa.
|
||||||
|
4. **Nie usuwaj testów, asercji ani logów audytowych**, żeby "przeszła weryfikacja".
|
||||||
|
Czerwony test to sygnał do poprawy kodu produkcyjnego, nie do usunięcia testu.
|
||||||
|
5. **Nie dodawaj nowych zależności zewnętrznych** bez jawnego polecenia w zadaniu.
|
||||||
|
Nowa zależność = decyzja architektoniczna, nie efekt uboczny migracji.
|
||||||
|
6. **Brak dostępu do sieci publicznej.** Nie próbuj pobierać pakietów ani dokumentacji z internetu -
|
||||||
|
cała wiedza migracyjna jest w skillach dostarczonych przez APM.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: sdk-upgrade
|
||||||
|
description: Wsad zadania dla sieci agentowej - podniesienie wersji SDK wraz z migracją API
|
||||||
|
inputs:
|
||||||
|
- name: package
|
||||||
|
description: Nazwa pakietu dystrybucyjnego (np. acme-sdk)
|
||||||
|
required: true
|
||||||
|
- name: to_version
|
||||||
|
description: Wersja docelowa (np. 2.1.0)
|
||||||
|
required: true
|
||||||
|
- name: from_version
|
||||||
|
description: Wersja aktualna, jeśli znana
|
||||||
|
required: false
|
||||||
|
- name: repo
|
||||||
|
description: Ścieżka do sklonowanego repozytorium
|
||||||
|
required: true
|
||||||
|
- name: constraints
|
||||||
|
description: Dodatkowe ograniczenia z issue / decyzji architektonicznej
|
||||||
|
required: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Zadanie: upgrade {{package}} -> {{to_version}}
|
||||||
|
|
||||||
|
W repozytorium `{{repo}}` podnieś wersję pakietu **{{package}}** z `{{from_version}}` do `{{to_version}}`
|
||||||
|
i dostosuj kod do nowego API.
|
||||||
|
|
||||||
|
## Zakres
|
||||||
|
|
||||||
|
- Deklaracja wersji w plikach zależności.
|
||||||
|
- Wszystkie miejsca użycia biblioteki w kodzie źródłowym i testach.
|
||||||
|
- Zero zmian niezwiązanych z migracją.
|
||||||
|
|
||||||
|
## Ograniczenia
|
||||||
|
|
||||||
|
{{constraints}}
|
||||||
|
|
||||||
|
## Definicja ukończenia
|
||||||
|
|
||||||
|
1. Weryfikacja repozytorium (build + testy) przechodzi na zielono.
|
||||||
|
2. W kodzie nie ma już użyć API usuniętego w wersji docelowej.
|
||||||
|
3. Zmiana jest opisana w merge requeście zgodnie ze skillem `merge-request-authoring`.
|
||||||
|
4. Elementy wymagające decyzji człowieka są jawnie wypisane, a nie obejściem załatwione.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: merge-request-authoring
|
||||||
|
description: Redagowanie tytułu i opisu merge requesta dla zmiany wykonanej przez agenta - struktura opisu, informacja o ryzyku, ślad audytowy i lista kontrolna dla recenzenta. Użyj na końcu przebiegu, gdy zmiana jest gotowa do publikacji.
|
||||||
|
license: Apache-2.0
|
||||||
|
metadata:
|
||||||
|
owner: pubi-platform
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Opis merge requesta
|
||||||
|
|
||||||
|
Odbiorcą jest **człowiek, który bierze odpowiedzialność za merge**. Opis ma mu pozwolić
|
||||||
|
podjąć decyzję bez czytania całego diffa.
|
||||||
|
|
||||||
|
## Tytuł
|
||||||
|
|
||||||
|
Conventional Commits, tryb rozkazujący, bez kropki na końcu, maks. 72 znaki:
|
||||||
|
`build(deps): podniesienie acme-sdk 1.4.2 -> 2.1.0 wraz z migracją API`
|
||||||
|
|
||||||
|
## Struktura opisu
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Co i dlaczego
|
||||||
|
2-4 zdania. Cel zmiany i skąd przyszło zadanie (issue / harmonogram / audyt zależności).
|
||||||
|
|
||||||
|
## Zakres zmian
|
||||||
|
- `ścieżka/pliku.py` - co konkretnie zmienione
|
||||||
|
(tylko pliki realnie w diffie)
|
||||||
|
|
||||||
|
## Weryfikacja
|
||||||
|
Komenda, wynik, liczba testów. Wklej istotny fragment logu.
|
||||||
|
|
||||||
|
## Ryzyko i ograniczenia
|
||||||
|
Co może się zepsuć na produkcji, czego agent NIE zweryfikował,
|
||||||
|
elementy oznaczone jako `requires_human`.
|
||||||
|
|
||||||
|
## Ślad audytowy
|
||||||
|
ID przebiegu, wersje pakietów kontekstowych (apm.lock.yaml), użyte modele, liczba iteracji.
|
||||||
|
|
||||||
|
## Lista kontrolna dla recenzenta
|
||||||
|
- [ ] Diff nie zawiera zmian spoza zakresu
|
||||||
|
- [ ] Brak zmian w testach maskujących błąd
|
||||||
|
- [ ] Wersja zależności zgodna z zadaniem
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zasady
|
||||||
|
|
||||||
|
- **Zero marketingu.** Bez "successfully", "seamlessly", "comprehensive".
|
||||||
|
- **Nie zgaduj wyników.** Cytuj wyłącznie realny log weryfikacji.
|
||||||
|
- **Nazywaj to, czego nie wiesz.** Sekcja o ryzyku jest ważniejsza niż lista zmian.
|
||||||
|
- **Nigdy nie cytuj sekretów** ani fragmentów danych produkcyjnych z logów.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: sdk-version-upgrade
|
||||||
|
description: Podniesienie wersji SDK lub biblioteki wraz z migracją wywołań API - planowanie zmiany, kolejność edycji, obsługa breaking changes i kryteria akceptacji. Użyj gdy zadanie mówi o bumpie wersji, upgradzie SDK, migracji do nowego major release lub usunięciu deprecated API.
|
||||||
|
license: Apache-2.0
|
||||||
|
allowed-tools:
|
||||||
|
- read_file
|
||||||
|
- search_repo
|
||||||
|
- list_files
|
||||||
|
- write_file
|
||||||
|
- replace_in_file
|
||||||
|
- run_verification
|
||||||
|
metadata:
|
||||||
|
owner: pubi-platform
|
||||||
|
version: "1.0.0"
|
||||||
|
references: acme-sdk-2.x-migration.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Upgrade wersji SDK
|
||||||
|
|
||||||
|
## Zasada nadrzędna
|
||||||
|
|
||||||
|
Upgrade to **dwie rozłączne zmiany**: (a) deklaracja wersji w pliku zależności,
|
||||||
|
(b) dostosowanie kodu do nowego API. Deklarację wersji ustawia deterministycznie silnik pipeline'u -
|
||||||
|
Twoim zadaniem jest wyłącznie (b). Nie edytuj ręcznie plików zależności, chyba że plan mówi inaczej.
|
||||||
|
|
||||||
|
## Kolejność pracy
|
||||||
|
|
||||||
|
1. Przeczytaj notatkę migracyjną dla docelowej wersji z katalogu `references/` tego skilla.
|
||||||
|
Jeśli brakuje notatki dla danej biblioteki - **zatrzymaj się** i zgłoś `requires_human`.
|
||||||
|
Nie migruj API "z pamięci modelu".
|
||||||
|
2. Zbuduj mapę zmian: `stary symbol -> nowy symbol -> plik(i) do zmiany`.
|
||||||
|
3. Edytuj plik po pliku, najmniejszą możliwą zmianą (`replace_in_file`, nie przepisywanie pliku).
|
||||||
|
4. Po każdym pliku uruchom weryfikację (`run_verification`). Czerwony wynik naprawiaj natychmiast,
|
||||||
|
zanim przejdziesz dalej - kumulowanie błędów uniemożliwia ustalenie przyczyny.
|
||||||
|
5. Na koniec sprawdź, czy nie zostały użycia starego API: `search_repo` po każdym symbolu z mapy.
|
||||||
|
|
||||||
|
## Breaking changes - reguły decyzyjne
|
||||||
|
|
||||||
|
| Sytuacja | Działanie |
|
||||||
|
|---|---|
|
||||||
|
| Zmiana nazwy klasy/metody, ta sama semantyka | Migruj bezpośrednio |
|
||||||
|
| Zmiana nazw parametrów | Migruj, zachowując wartości wywołań 1:1 |
|
||||||
|
| Zasób wymaga teraz zamknięcia / context managera | Użyj `with`, nie dodawaj ręcznego `close()` |
|
||||||
|
| Nowy wymagany parametr bez sensownej wartości domyślnej | `requires_human` - to decyzja biznesowa |
|
||||||
|
| Usunięta funkcjonalność bez zamiennika | `requires_human`, nie obchodź problemu własną implementacją |
|
||||||
|
|
||||||
|
## Kryteria akceptacji
|
||||||
|
|
||||||
|
- Wszystkie testy repozytorium zielone.
|
||||||
|
- Zero wystąpień starych symboli poza plikami changelog/dokumentacji.
|
||||||
|
- Diff nie zawiera zmian niezwiązanych z migracją.
|
||||||
|
- Deklarowana wersja zależności odpowiada wersji docelowej z zadania.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Maszynowo wykonywalna wersja notatki migracyjnej acme-sdk-2.x-migration.md.
|
||||||
|
#
|
||||||
|
# Zasada architektoniczna: co da się zmigrować deterministycznie, migrujemy regułą,
|
||||||
|
# a nie modelem. LLM jest od reszty - od przypadków, których reguła nie obejmuje,
|
||||||
|
# i od oceny, czy wynik ma sens. Reguły są częścią pakietu APM, więc podlegają
|
||||||
|
# temu samemu wersjonowaniu i przeglądowi co skill.
|
||||||
|
package: acme-sdk
|
||||||
|
applies_to: ">=2.0.0,<3.0.0"
|
||||||
|
file_glob: "*.py"
|
||||||
|
rules:
|
||||||
|
- id: import-client
|
||||||
|
description: "Client -> AcmeClient w imporcie"
|
||||||
|
pattern: '\bfrom acme import Client\b'
|
||||||
|
replacement: 'from acme import AcmeClient'
|
||||||
|
- id: constructor
|
||||||
|
description: "Konstruktor: nowa nazwa klasy, endpoint -> base_url"
|
||||||
|
pattern: '\bClient\(\s*api_key=(?P<key>[^,]+),\s*endpoint=(?P<endpoint>[^)]+)\)'
|
||||||
|
replacement: 'AcmeClient(api_key=\g<key>, base_url=\g<endpoint>)'
|
||||||
|
- id: send-message
|
||||||
|
description: "send() -> messages.create() z nowymi nazwami parametrów"
|
||||||
|
pattern: '\.send\(\s*to=(?P<to>[^,]+),\s*body=(?P<body>[^)]+)\)'
|
||||||
|
replacement: '.messages.create(recipient=\g<to>, content=\g<body>)'
|
||||||
|
- id: drop-close
|
||||||
|
description: "Klient 2.x zwalnia zasoby automatycznie - close() usunięte z API"
|
||||||
|
pattern: '^[ \t]*[A-Za-z_][A-Za-z0-9_]*\.close\(\)[ \t]*\n'
|
||||||
|
replacement: ''
|
||||||
|
multiline: true
|
||||||
|
- id: response-attribute
|
||||||
|
description: "Odpowiedź jest obiektem Message, nie słownikiem"
|
||||||
|
pattern: '(?P<var>\b[a-z_][a-z0-9_]*)\[[''"]id[''"]\]'
|
||||||
|
replacement: '\g<var>.id'
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# acme-sdk: migracja 1.x -> 2.x
|
||||||
|
|
||||||
|
> Przykładowa notatka migracyjna. W realnym wdrożeniu ten katalog zasila pakiet APM
|
||||||
|
> utrzymywany przez zespół właściciela SDK (np. `pubi/apm-packages/acme-sdk-migrations`),
|
||||||
|
> a `apm install` dostarcza go do joba CI z pinem po tagu.
|
||||||
|
|
||||||
|
## Zmiany łamiące kompatybilność
|
||||||
|
|
||||||
|
| 1.x | 2.x | Uwagi |
|
||||||
|
|---|---|---|
|
||||||
|
| `from acme import Client` | `from acme import AcmeClient` | zmiana wyłącznie nazwy |
|
||||||
|
| `Client(api_key=..., endpoint=...)` | `AcmeClient(api_key=..., base_url=...)` | `endpoint` -> `base_url` |
|
||||||
|
| `client.send(to=..., body=...)` | `client.messages.create(recipient=..., content=...)` | wysyłka przez sub-resource |
|
||||||
|
| `client.close()` | `with AcmeClient(...) as client:` | klient jest context managerem |
|
||||||
|
| zwracany `dict` z kluczem `id` | obiekt `Message` z atrybutem `.id` | dostęp `msg["id"]` -> `msg.id` |
|
||||||
|
|
||||||
|
## Bez zmian
|
||||||
|
|
||||||
|
- `AcmeError` pozostaje w `acme.errors`.
|
||||||
|
- Format kluczy API i semantyka retry są niezmienione.
|
||||||
|
|
||||||
|
## Kolejność migracji
|
||||||
|
|
||||||
|
1. Import i konstrukcja klienta.
|
||||||
|
2. Wywołania wysyłki.
|
||||||
|
3. Zarządzanie cyklem życia (`close()` -> `with`).
|
||||||
|
4. Odczyt pól odpowiedzi.
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Znajduje użycia modułu w repozytorium wraz z kontekstem.
|
||||||
|
# Użycie: find_usages.sh <katalog-repo> <nazwa-modulu>
|
||||||
|
set -euo pipefail
|
||||||
|
repo="${1:?podaj katalog repozytorium}"
|
||||||
|
module="${2:?podaj nazwę modułu importu}"
|
||||||
|
rg --line-number --with-filename \
|
||||||
|
-e "^\s*import\s+${module}\b" \
|
||||||
|
-e "^\s*from\s+${module}\b" \
|
||||||
|
-e "\b${module}\." \
|
||||||
|
--glob '!**/.git/**' --glob '!**/node_modules/**' \
|
||||||
|
"$repo" || echo "brak użyć modułu ${module}"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# ---- Backend LLM -----------------------------------------------------------
|
||||||
|
# Najprościej: nie ustawiaj tego ręcznie, tylko uruchom
|
||||||
|
# task llm:up && eval "$(task llm:env)"
|
||||||
|
# Zmienne poniżej to dokładnie to, co wygeneruje `task llm:env` z llm/models.yml.
|
||||||
|
#
|
||||||
|
# Wybór backendu: auto (domyślnie) | vllm-gpu | vllm-metal | ollama | mock
|
||||||
|
# LLM_BACKEND=ollama task llm:up
|
||||||
|
CODEMOD_MODEL_PROVIDER=vllm # vllm | openai_like | ollama
|
||||||
|
CODEMOD_LLM_BASE_URL=https://vllm.apps.ocp.internal/v1
|
||||||
|
CODEMOD_LLM_API_KEY=dummy # w CI: zmienna masked/protected
|
||||||
|
CODEMOD_MODEL_PLANNER=Qwen/Qwen3-32B
|
||||||
|
CODEMOD_MODEL_CODER=Qwen/Qwen3-Coder-30B
|
||||||
|
CODEMOD_MODEL_REVIEWER=Qwen/Qwen3-32B
|
||||||
|
CODEMOD_MODEL_SCRIBE=Qwen/Qwen3-8B
|
||||||
|
|
||||||
|
# ---- Budżety i guardraile --------------------------------------------------
|
||||||
|
CODEMOD_MAX_ITERATIONS=4
|
||||||
|
CODEMOD_MAX_FILES_CHANGED=40
|
||||||
|
CODEMOD_MAX_FILE_BYTES=400000
|
||||||
|
CODEMOD_TOOL_CALL_LIMIT=60
|
||||||
|
CODEMOD_VERIFY_TIMEOUT_S=900
|
||||||
|
|
||||||
|
# ---- GitLab ---------------------------------------------------------------
|
||||||
|
CI_SERVER_URL=https://gitlab.internal
|
||||||
|
CI_PROJECT_ID=1234
|
||||||
|
CODEMOD_GITLAB_TOKEN= # PAT/Project Access Token z api+write_repository
|
||||||
|
|
||||||
|
# ---- Lokalne uruchamianie modelu (opcjonalne) ------------------------------
|
||||||
|
# HUGGING_FACE_HUB_TOKEN= # modele za bramką na Hugging Face
|
||||||
|
# HF_CACHE=$HOME/.cache/huggingface # współdzielony cache modeli dla kontenera vLLM
|
||||||
|
# LLM_PORT=8000 # port vLLM
|
||||||
|
# LLM_WAIT_TIMEOUT=900 # ile sekund czekać na wstanie endpointu
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
.task/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
# APM: artefakty instalacji zależności kontekstowych
|
||||||
|
apm_modules/
|
||||||
|
.claude/
|
||||||
|
.github/instructions/
|
||||||
|
.cursor/
|
||||||
|
# artefakty przebiegów
|
||||||
|
.runs/
|
||||||
|
workspace/
|
||||||
|
*.patch
|
||||||
|
.env
|
||||||
+105
@@ -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: "."
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# agentic-codemod-pipeline
|
||||||
|
|
||||||
|
Repozytorium wzorcowe: **sieć agentowa (agno) modyfikująca kod, zasilana kontekstem
|
||||||
|
z pakietów APM, uruchamiana jako pipeline w GitLab CI.**
|
||||||
|
|
||||||
|
Przykładowy przypadek użycia: podniesienie wersji SDK wraz z migracją wywołań API.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install go-task # albo: pip install go-task-bin
|
||||||
|
|
||||||
|
task install # .venv + zależności
|
||||||
|
task test # 46 testów, bez modelu językowego
|
||||||
|
task demo # pełny przebieg na fixture, offline
|
||||||
|
task --list # reszta zadań
|
||||||
|
```
|
||||||
|
|
||||||
|
Wszystko dzieje się w lokalnym `.venv` - nic nie trafia do systemowego Pythona.
|
||||||
|
Gdy coś nie działa, zacznij od `task doctor`.
|
||||||
|
|
||||||
|
## Skąd się bierze zachowanie agentów
|
||||||
|
|
||||||
|
Z pakietu APM, nie z kodu:
|
||||||
|
|
||||||
|
```
|
||||||
|
.apm/
|
||||||
|
├── agents/ scout, planner, coder, reviewer, scribe (model, narzędzia, schemat wyjścia)
|
||||||
|
├── skills/ repo-recon, sdk-version-upgrade, safe-code-edit, merge-request-authoring
|
||||||
|
├── instructions/ guardraile bezpieczeństwa, konwencje języka
|
||||||
|
├── prompts/ wsad zadania (sdk-upgrade, dependency-audit)
|
||||||
|
└── context/ kontrakt przebiegu wspólny dla wszystkich ról
|
||||||
|
```
|
||||||
|
|
||||||
|
`apm.yml` deklaruje, skąd przychodzi reszta kontekstu; `apm-policy.yml` ogranicza, co wolno
|
||||||
|
zainstalować; `apm.lock.yaml` pinuje wersje. Silnik w `src/agentic_codemod/` kompiluje
|
||||||
|
te prymitywy do obiektów agno - nie zawiera ani jednego zaszytego promptu.
|
||||||
|
|
||||||
|
## Przepływ
|
||||||
|
|
||||||
|
```
|
||||||
|
intake → recon → plan → [bump wersji → pętla(implement → verify) → review → opis MR] → bramka CI → MR
|
||||||
|
```
|
||||||
|
|
||||||
|
Kroki deterministyczne (wykrycie ekosystemu, podbicie wersji, uruchomienie testów, git)
|
||||||
|
robi kod. Model dostaje tylko to, czego nie da się zrobić inaczej. Szczegóły podziału:
|
||||||
|
[docs/architecture.md](docs/architecture.md).
|
||||||
|
|
||||||
|
## Trzy tryby, jedna topologia
|
||||||
|
|
||||||
|
| Tryb | Mózg | Zastosowanie |
|
||||||
|
|---|---|---|
|
||||||
|
| `--offline` | reguły codemod z pakietu APM | smoke test pipeline'u, bez GPU i bez internetu |
|
||||||
|
| domyślny + backend `mock` | agenci agno na atrapie serwera OpenAI | test ścieżki agentowej w CI: narzędzia, schematy, pętla weryfikacji |
|
||||||
|
| domyślny + realny model | agenci agno na vLLM | przypadki wykraczające poza reguły |
|
||||||
|
|
||||||
|
## Model językowy od zera
|
||||||
|
|
||||||
|
Nie ma jednej komendy uruchamiającej vLLM na Linuksie i na macOS - Docker na Macu
|
||||||
|
nie ma dostępu do Metala. Kontrakt jest jeden (endpoint zgodny z OpenAI), a backend
|
||||||
|
dobiera się do platformy sam:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task llm:list # co jest dostępne i co pasuje do tej maszyny
|
||||||
|
task llm:up # Linux+NVIDIA -> vLLM w kontenerze, macOS -> vllm-metal (MLX)
|
||||||
|
eval "$(task llm:env)" # zmienne dla silnika
|
||||||
|
task llm:logs # podgląd logów backendu na żywo
|
||||||
|
task demo:llm # przebieg agentowy na fixture
|
||||||
|
task llm:down
|
||||||
|
```
|
||||||
|
|
||||||
|
Wybór ręczny: `LLM_BACKEND=ollama task llm:up`, `LLM_BACKEND=mock task demo:llm`.
|
||||||
|
Konfiguracja backendów i modeli per rola: [`llm/models.yml`](llm/models.yml).
|
||||||
|
|
||||||
|
## Dokumentacja
|
||||||
|
|
||||||
|
- [Architektura](docs/architecture.md) - warstwy, przepływ, model bezpieczeństwa, granice
|
||||||
|
- [Runbook](docs/runbook.md) - uruchamianie, kody wyjścia, typowe sytuacje, rozszerzanie
|
||||||
|
- ADR: [APM jako łańcuch dostaw kontekstu](docs/adr/0001-apm-jako-lancuch-dostaw-kontekstu.md) ·
|
||||||
|
[Deterministycznie vs model](docs/adr/0002-podzial-na-krok-deterministyczny-i-model.md) ·
|
||||||
|
[Runtime w jobie CI](docs/adr/0003-runtime-w-efemerycznym-jobie-gitlab-ci.md) ·
|
||||||
|
[Guardraile w dwóch warstwach](docs/adr/0004-guardraile-w-dwoch-warstwach.md) ·
|
||||||
|
[Backend LLM per platforma](docs/adr/0005-backend-llm-per-platforma.md)
|
||||||
|
|
||||||
|
## Struktura
|
||||||
|
|
||||||
|
```
|
||||||
|
.apm/ kontekst agentowy (źródło prawdy o zachowaniu sieci)
|
||||||
|
src/agentic_codemod/
|
||||||
|
├── apm/ ładowanie prymitywów APM i kompilacja do agno
|
||||||
|
├── adapters/ pip / maven / npm - wiedza deterministyczna o ekosystemie
|
||||||
|
├── tools/ sandboxowane narzędzia agenta (pliki, weryfikacja, git)
|
||||||
|
├── workflow/ topologia agno, kroki, reguły codemod, uruchamianie
|
||||||
|
├── observability/ ślad audytowy z redakcją sekretów
|
||||||
|
└── integrations/ GitLab (merge request, komentarze)
|
||||||
|
llm/ backendy LLM: models.yml, docker-compose, vllm-metal, atrapa serwera OpenAI
|
||||||
|
examples/fixtures/acme-app aplikacja na SDK 1.x + atrapa SDK 2.x (test regresyjny e2e)
|
||||||
|
.gitlab/ci/ szablon do include w repozytoriach docelowych
|
||||||
|
ci/ obraz runnera
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wymagania
|
||||||
|
|
||||||
|
Python 3.10+, git, ripgrep. Do instalacji kontekstu: APM CLI.
|
||||||
|
|
||||||
|
Do przebiegu z modelem wystarczy `task llm:up`: na Linuksie z GPU NVIDIA potrzebny
|
||||||
|
Docker, na macOS Apple Silicon - arm64 Python 3.12 i Xcode Command Line Tools.
|
||||||
|
Testy i tryb offline nie wymagają niczego z tej listy.
|
||||||
+382
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Manifest APM (Microsoft Agent Package Manager).
|
||||||
|
# Ten plik jest JEDYNYM źródłem prawdy o kontekście, jaki dostają agenci:
|
||||||
|
# instrukcje, skille, prompty i definicje agentów. Kod w src/ jest tylko silnikiem,
|
||||||
|
# który te prymitywy kompiluje do obiektów agno.
|
||||||
|
name: agentic-codemod
|
||||||
|
version: "0.1.0"
|
||||||
|
description: Sieć agentowa modyfikująca kod (upgrade SDK, migracje API) uruchamiana w GitLab CI
|
||||||
|
author:
|
||||||
|
name: PUBI Platform Team
|
||||||
|
license: Apache-2.0
|
||||||
|
type: hybrid # instructions + skills + prompts + agents
|
||||||
|
|
||||||
|
# Harnessy, dla których APM generuje natywne pliki podczas `apm install`.
|
||||||
|
# Silnik agno czyta bezpośrednio z .apm/ oraz apm_modules/, więc lista jest
|
||||||
|
# istotna tylko dla ludzi pracujących w IDE na tym repo.
|
||||||
|
targets:
|
||||||
|
- copilot
|
||||||
|
- claude
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
apm:
|
||||||
|
# Wewnętrzne pakiety kontekstowe organizacji (git = rejestr, nie potrzeba marketplace).
|
||||||
|
# Odkomentuj i wskaż swoje repozytoria - pinowanie po tagu jest wymagane przez apm-policy.yml.
|
||||||
|
# - pubi/apm-packages/gitlab-ci-pipelines#v1.2.0
|
||||||
|
# - pubi/apm-packages/java-sdk-migrations#v0.4.1
|
||||||
|
# - pubi/apm-packages/bank-secure-coding#v2.0.0
|
||||||
|
[]
|
||||||
|
mcp:
|
||||||
|
# Serwery MCP są opcjonalne; sieć agentowa działa bez nich (narzędzia natywne agno).
|
||||||
|
[]
|
||||||
|
|
||||||
|
scripts:
|
||||||
|
# `apm run <name>` - wygodne wywołanie promptu w lokalnym harnessie developera.
|
||||||
|
sdk-upgrade: "copilot -p .apm/prompts/sdk-upgrade.prompt.md"
|
||||||
|
dependency-audit: "copilot -p .apm/prompts/dependency-audit.prompt.md"
|
||||||
@@ -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"]
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wejście dla joba CI: zainstaluj kontekst, sprawdź go, uruchom przebieg.
|
||||||
|
# Skrypt jest celowo cienki - logika mieszka w CLI, żeby dało się ją odtworzyć lokalnie.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${CODEMOD_PACKAGE:?ustaw CODEMOD_PACKAGE}"
|
||||||
|
: "${CODEMOD_TO_VERSION:?ustaw CODEMOD_TO_VERSION}"
|
||||||
|
RUN_DIR="${CODEMOD_RUN_DIR:-.runs/local}"
|
||||||
|
|
||||||
|
apm install
|
||||||
|
apm audit
|
||||||
|
agentic-codemod context --json > "${RUN_DIR}/apm-context.json" 2>/dev/null || true
|
||||||
|
|
||||||
|
exec agentic-codemod run \
|
||||||
|
--repo "${CI_PROJECT_DIR:-$PWD}" \
|
||||||
|
--in-place \
|
||||||
|
--package "${CODEMOD_PACKAGE}" \
|
||||||
|
--to-version "${CODEMOD_TO_VERSION}" \
|
||||||
|
--module "${CODEMOD_MODULE:-}" \
|
||||||
|
--constraints "${CODEMOD_CONSTRAINTS:-}" \
|
||||||
|
--run-dir "${RUN_DIR}" \
|
||||||
|
"$@"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# ADR-0001: APM jako łańcuch dostaw kontekstu agentowego
|
||||||
|
|
||||||
|
- Status: przyjęty
|
||||||
|
- Data: 2026-08-28
|
||||||
|
|
||||||
|
## Kontekst
|
||||||
|
|
||||||
|
Prompty, skille i guardraile dla agentów mają wszystkie cechy zależności: są współdzielone
|
||||||
|
między repozytoriami, zmieniają się w czasie, mają właścicieli i wpływają na wynik.
|
||||||
|
Trzymane jako pliki w repozytorium silnika stają się nierozróżnialne od kodu: nie da się ich
|
||||||
|
niezależnie wersjonować, nie da się powiedzieć "ten przebieg użył wiedzy migracyjnej w wersji 1.2.0".
|
||||||
|
|
||||||
|
## Decyzja
|
||||||
|
|
||||||
|
Kontekst agentowy dostarczamy pakietami APM (Microsoft Agent Package Manager):
|
||||||
|
`apm.yml` deklaruje zależności, `apm install` rozwiązuje je do `apm_modules/`,
|
||||||
|
`apm.lock.yaml` pinuje commity i hashe, `apm-policy.yml` ogranicza dopuszczalne źródła
|
||||||
|
i prymitywy, `apm audit` wykrywa dryf.
|
||||||
|
|
||||||
|
Silnik (Python + agno) czyta prymitywy z `.apm/` oraz `apm_modules/` i kompiluje je
|
||||||
|
do obiektów agno. Prymitywy lokalne mają pierwszeństwo przed zainstalowanymi.
|
||||||
|
|
||||||
|
## Konsekwencje
|
||||||
|
|
||||||
|
**Pozytywne**
|
||||||
|
|
||||||
|
- Zespół właściciela SDK dostarcza wiedzę migracyjną jako pakiet, bez dostępu do silnika.
|
||||||
|
- Hash lockfile'a w manifeście przebiegu odpowiada na pytanie "co dokładnie wiedział model".
|
||||||
|
- Polityka instalacyjna jest egzekwowana w CI, a nie w regulaminie.
|
||||||
|
|
||||||
|
**Negatywne**
|
||||||
|
|
||||||
|
- Dodatkowa zależność narzędziowa (APM CLI) w obrazie runnera.
|
||||||
|
- Potrzebny wewnętrzny rejestr pakietów (w praktyce: repozytoria GitLaba) i dyscyplina tagowania.
|
||||||
|
- Ryzyko rozjazdu między wersją silnika a formatem prymitywów - łagodzone testem
|
||||||
|
`test_apm_context.py`, który waliduje kontekst przy każdym MR.
|
||||||
|
|
||||||
|
## Rozważane alternatywy
|
||||||
|
|
||||||
|
- **Prompty w repozytorium silnika.** Najprostsze, ale uniemożliwia niezależne wersjonowanie
|
||||||
|
wiedzy dziedzinowej i rozmywa własność.
|
||||||
|
- **Baza wektorowa z dokumentacją migracji.** Nieodtwarzalne: dwa przebiegi na tym samym commicie
|
||||||
|
mogą dostać inny kontekst. Odpada w środowisku wymagającym audytu.
|
||||||
|
- **Własny format pakietu.** Koszt utrzymania bez zysku; APM daje lockfile, politykę i skanowanie
|
||||||
|
ukrytego Unicode z pudełka, opierając się na otwartych standardach (AGENTS.md, Agent Skills, MCP).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# ADR-0002: Deterministycznie tyle, ile się da; model tylko na resztę
|
||||||
|
|
||||||
|
- Status: przyjęty
|
||||||
|
- Data: 2026-08-28
|
||||||
|
|
||||||
|
## Kontekst
|
||||||
|
|
||||||
|
Migracja SDK to w większości mechaniczne podmiany. Powierzenie ich modelowi kosztuje tokeny,
|
||||||
|
wydłuża przebieg i wprowadza wariancję tam, gdzie wariancja jest czystą stratą -
|
||||||
|
nikt nie chce, żeby dwa uruchomienia dawały inny diff dla tej samej zmiany nazwy metody.
|
||||||
|
|
||||||
|
## Decyzja
|
||||||
|
|
||||||
|
Dzielimy pracę wzdłuż linii "czy da się to zrobić kodem":
|
||||||
|
|
||||||
|
1. Wykrycie ekosystemu, plików zależności i komendy testowej - adapter (`adapters/`).
|
||||||
|
2. Podbicie deklaracji wersji - adapter.
|
||||||
|
3. Migracja objęta regułami - silnik reguł czytający `*.codemod.yaml` z pakietu APM.
|
||||||
|
4. Reszta - agent `coder` z LLM.
|
||||||
|
5. Ocena wyniku - agent `reviewer` **oraz** niezależna kontrola mechaniczna.
|
||||||
|
|
||||||
|
Reguły codemod są częścią pakietu APM (leżą obok notatki migracyjnej), więc podlegają
|
||||||
|
temu samemu przeglądowi i wersjonowaniu co wiedza dla modelu.
|
||||||
|
|
||||||
|
## Konsekwencje
|
||||||
|
|
||||||
|
**Pozytywne**
|
||||||
|
|
||||||
|
- Powstaje tryb `--offline`: ta sama topologia bez modelu. Smoke test całego pipeline'u
|
||||||
|
na każdym MR, bez GPU i bez internetu.
|
||||||
|
- Punkt odniesienia: widać, ile przypadków model faktycznie dołożył ponad reguły.
|
||||||
|
- Mniejszy koszt i krótszy przebieg dla typowych migracji.
|
||||||
|
|
||||||
|
**Negatywne**
|
||||||
|
|
||||||
|
- Dwie ścieżki do utrzymania (`RuleBrain`, `LlmBrain`) i wspólny kontrakt między nimi.
|
||||||
|
- Reguły regexowe mają znane ograniczenia (zmiany strukturalne, wieloliniowe konteksty).
|
||||||
|
Świadomie nie budujemy własnego silnika AST - od tego są narzędzia dziedzinowe
|
||||||
|
(OpenRewrite, jscodeshift), które można podpiąć jako kolejny adapter.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ADR-0003: Runtime w efemerycznym jobie GitLab CI, nie w usłudze
|
||||||
|
|
||||||
|
- Status: przyjęty
|
||||||
|
- Data: 2026-08-28
|
||||||
|
|
||||||
|
## Kontekst
|
||||||
|
|
||||||
|
agno pozwala wystawić agentów jako usługę (AgentOS/FastAPI) z bazą sesji, pamięcią i UI.
|
||||||
|
Kuszące, ale dla zadania "zmodyfikuj kod i otwórz MR" oznacza nową usługę produkcyjną:
|
||||||
|
własne SLA, uwierzytelnianie, przechowywanie stanu, przegląd bezpieczeństwa i dostęp
|
||||||
|
do repozytoriów z długożyjącego procesu.
|
||||||
|
|
||||||
|
## Decyzja
|
||||||
|
|
||||||
|
Runtime to efemeryczny job GitLab CI. Jedno uruchomienie = jeden job = jeden katalog roboczy
|
||||||
|
= jeden komplet artefaktów. Stan przebiegu żyje w artefaktach (`run.json`, `trace.jsonl`,
|
||||||
|
`changes.patch`), nie w bazie. Uprawnienia to uprawnienia joba (token projektowy),
|
||||||
|
a nie konta usługowego z dostępem do wszystkiego.
|
||||||
|
|
||||||
|
Wyzwalanie: issue z labelką (webhook → trigger token), zdarzenie na MR, harmonogram
|
||||||
|
oraz ręczny formularz w "Run pipeline" (zmienne z `description` i `options`).
|
||||||
|
|
||||||
|
## Konsekwencje
|
||||||
|
|
||||||
|
**Pozytywne**
|
||||||
|
|
||||||
|
- Zero nowej usługi do utrzymania i przeglądu bezpieczeństwa.
|
||||||
|
- Naturalna izolacja: brak stanu współdzielonego między przebiegami.
|
||||||
|
- Bramka manualna w CI daje właściciela podpisu pod zmianą.
|
||||||
|
- Limity zasobów i czasu przychodzą z platformy CI.
|
||||||
|
|
||||||
|
**Negatywne**
|
||||||
|
|
||||||
|
- Brak pamięci między przebiegami - powtórka zaczyna od zera (świadomy kompromis:
|
||||||
|
pamięć między przebiegami w narzędziu modyfikującym kod to więcej ryzyka niż korzyści).
|
||||||
|
- Zimny start: instalacja kontekstu APM w każdym przebiegu (łagodzone cache'em i artefaktami).
|
||||||
|
- Brak interaktywnego trybu "zapytaj człowieka w trakcie" - stąd wzorzec `requires_human`:
|
||||||
|
agent nie pyta, tylko oznacza i idzie dalej.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ADR-0004: Guardraile w dwóch warstwach - prompt nie jest zabezpieczeniem
|
||||||
|
|
||||||
|
- Status: przyjęty
|
||||||
|
- Data: 2026-08-28
|
||||||
|
|
||||||
|
## Kontekst
|
||||||
|
|
||||||
|
Instrukcje w promptcie ("nie modyfikuj .gitlab-ci.yml") działają w większości przypadków,
|
||||||
|
ale są miękkie: zależą od modelu, długości kontekstu i sformułowania zadania.
|
||||||
|
W repozytorium bankowym pytanie audytora nie brzmi "czy model zwykle tego nie robi",
|
||||||
|
tylko "co się stanie, jeśli spróbuje".
|
||||||
|
|
||||||
|
## Decyzja
|
||||||
|
|
||||||
|
Każdy istotny guardrail istnieje dwukrotnie:
|
||||||
|
|
||||||
|
- jako instrukcja APM dla modelu (`security.instructions.md`) - żeby agent w ogóle nie próbował,
|
||||||
|
- jako mechanizm w kodzie (`WorkspaceTools`, `Settings.deny_globs`, budżety, brak powłoki) -
|
||||||
|
żeby próba zakończyła się błędem narzędzia zapisanym w śladzie audytowym.
|
||||||
|
|
||||||
|
Ta sama zasada dotyczy oceny wyniku: werdykt agenta `reviewer` jest uzupełniany niezależną
|
||||||
|
kontrolą mechaniczną (pliki objęte zakazem, zmiany w testach, budżet zakresu, status weryfikacji).
|
||||||
|
|
||||||
|
## Konsekwencje
|
||||||
|
|
||||||
|
**Pozytywne**
|
||||||
|
|
||||||
|
- Naruszenie guardraila jest zdarzeniem obserwowalnym, a nie niewidoczną zmianą w diffie.
|
||||||
|
- Da się odpowiedzieć na pytanie "co system uniemożliwia", a nie tylko "o co prosi".
|
||||||
|
- Testy zabezpieczeń (`test_workspace_tools.py`) są testami jednostkowymi, nie ćwiczeniem z promptowania.
|
||||||
|
|
||||||
|
**Negatywne**
|
||||||
|
|
||||||
|
- Duplikacja reguł w dwóch miejscach - trzeba je świadomie utrzymywać razem.
|
||||||
|
- Zbyt ciasne budżety potrafią zablokować poprawną, ale szeroką zmianę; wartości są
|
||||||
|
konfigurowalne przez zmienne środowiskowe i powinny być dostrajane per klasa repozytoriów.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# ADR-0005: Backend LLM wybierany per platforma, atrapa jako test double
|
||||||
|
|
||||||
|
- Status: przyjęty
|
||||||
|
- Data: 2026-08-29
|
||||||
|
|
||||||
|
## Kontekst
|
||||||
|
|
||||||
|
Docelowo pipeline korzysta z vLLM wystawionego na OpenShifcie. Do pracy nad samym
|
||||||
|
pipeline'em potrzebny jest jednak model uruchamialny lokalnie - i tu kończy się
|
||||||
|
jednorodność: **nie istnieje jedna komenda uruchamiająca vLLM na Linuksie i na macOS**.
|
||||||
|
|
||||||
|
- Linux z GPU NVIDIA: kontener `vllm/vllm-openai`, ścieżka najbliższa produkcji.
|
||||||
|
- macOS na Apple Silicon: Docker nie ma dostępu do Metala, więc kontener odpada.
|
||||||
|
Zostaje natywny plugin społecznościowy `vllm-project/vllm-metal` (backend MLX),
|
||||||
|
instalowany skryptem do własnego venva, wymagający arm64 Pythona 3.12 i Xcode CLT.
|
||||||
|
- Maszyna bez GPU i bez Apple Silicon: żaden z powyższych nie ma sensu.
|
||||||
|
|
||||||
|
Osobny problem: testy. Tryb `--offline` sprawdza `RuleBrain`, ale nie dotyka tego,
|
||||||
|
co jest w tym projekcie najbardziej kruche - budowy agentów z definicji APM,
|
||||||
|
wywoływania narzędzi przez model i parsowania strukturalnych wyjść. Uzależnienie
|
||||||
|
tego testu od GPU oznaczałoby, że nie uruchomi go nikt poza jedną maszyną.
|
||||||
|
|
||||||
|
## Decyzja
|
||||||
|
|
||||||
|
**Kontrakt jest jeden: endpoint zgodny z API OpenAI pod `CODEMOD_LLM_BASE_URL`.**
|
||||||
|
Backendy różnią się wyłącznie tym, jak ten endpoint powstaje. Konfiguracja mieszka
|
||||||
|
w `llm/models.yml` (jedno źródło prawdy: adresy, modele per rola, argumenty serwera),
|
||||||
|
a `task llm:up` wykrywa platformę i uruchamia właściwy wariant:
|
||||||
|
|
||||||
|
| Backend | Kiedy | Jak |
|
||||||
|
|---|---|---|
|
||||||
|
| `vllm-gpu` | Linux + NVIDIA + Docker | `docker compose --profile vllm-gpu` |
|
||||||
|
| `vllm-metal` | macOS Apple Silicon | natywny plugin MLX, `vllm serve` |
|
||||||
|
| `ollama` | awaryjnie, obie platformy | natywnie (Metal) lub kontener |
|
||||||
|
| `mock` | testy, CI | `llm/mock_server.py`, stdlib, bez pobierania |
|
||||||
|
|
||||||
|
Atrapa (`mock`) to **test double, nie symulator modelu**: odpowiada z góry ustalonymi
|
||||||
|
wywołaniami narzędzi i strukturami zestrojonymi z fixture'em `acme-app`, a routing
|
||||||
|
odpowiedzi opiera na nazwie agenta wstrzykiwanej przez agno do komunikatu systemowego.
|
||||||
|
Dzięki niej `tests/test_llm_path.py` przepuszcza pełny przebieg przez `LlmBrain`
|
||||||
|
na każdym MR, bez GPU i bez sieci.
|
||||||
|
|
||||||
|
## Konsekwencje
|
||||||
|
|
||||||
|
**Pozytywne**
|
||||||
|
|
||||||
|
- Projekt startuje od zera na Linuksie i na macOS jedną komendą; różnice platformowe
|
||||||
|
są schowane za `task llm:up`, a nie rozsypane po README.
|
||||||
|
- Ścieżka agentowa jest testowana automatycznie, nie tylko ręcznie na czyjejś maszynie.
|
||||||
|
- Podmiana modelu lub backendu to zmiana w `llm/models.yml`, nie w kodzie.
|
||||||
|
- Wszystkie backendy wymuszają `--enable-auto-tool-choice` z parserem `hermes`;
|
||||||
|
bez tego vLLM zwraca opis wywołania w treści zamiast `tool_calls` i agent `coder`
|
||||||
|
nie tknąłby żadnego pliku.
|
||||||
|
|
||||||
|
**Negatywne**
|
||||||
|
|
||||||
|
- Cztery ścieżki uruchomieniowe do utrzymania. Ograniczamy koszt tym, że wiedza
|
||||||
|
o nich jest w jednym pliku YAML plus jednym skrypcie, a nie w zadaniach.
|
||||||
|
- `vllm-metal` jest pluginem społecznościowym - może się rozjechać z upstreamem vLLM.
|
||||||
|
Dlatego Ollama zostaje jako wariant awaryjny na macOS.
|
||||||
|
- Atrapa jest zestrojona z fixture'em: zmiana fixture'u wymaga aktualizacji scenariusza.
|
||||||
|
To świadomy koszt - alternatywą jest brak testu tej ścieżki.
|
||||||
|
- Model 4B wystarcza do sprawdzenia hydrauliki, ale nie do realnych migracji.
|
||||||
|
Do pracy trzeba większego - stąd tabela rekomendacji per rola w `llm/models.yml`.
|
||||||
|
|
||||||
|
## Rozważane alternatywy
|
||||||
|
|
||||||
|
- **Tylko vLLM, macOS niech używa zdalnego endpointu.** Odpada: uniemożliwia pracę
|
||||||
|
offline i uzależnia każdego developera od środowiska współdzielonego.
|
||||||
|
- **Tylko Ollama.** Prostsze, ale rozjeżdża się z produkcją (inne API tool-callingu,
|
||||||
|
inne kwantyzacje), więc testy przestałyby cokolwiek mówić o zachowaniu na vLLM.
|
||||||
|
- **Brak atrapy, testy tylko na realnym modelu.** Niedeterministyczne i niedostępne
|
||||||
|
w CI; test, który czasem przechodzi, jest gorszy niż brak testu.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Architektura sieci agentowej modyfikującej kod
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
Zmiany typu "podnieś wersję SDK w 60 repozytoriach" są mechanicznie proste, ale kosztowne:
|
||||||
|
każde repozytorium ma inny układ, inne miejsca użycia biblioteki i inny poziom pokrycia testami.
|
||||||
|
Klasyczna automatyzacja (skrypt + sed) załatwia 70% przypadków i zostawia najgorsze 30%.
|
||||||
|
Agent z LLM załatwia pozostałe 30%, ale wprowadza trzy nowe problemy: niepowtarzalność,
|
||||||
|
brak audytowalności i nieograniczony zakres zmian.
|
||||||
|
|
||||||
|
Ten projekt jest odpowiedzią na pytanie: **jak wpuścić agenta do repozytorium tak,
|
||||||
|
żeby dało się to pokazać audytorowi.**
|
||||||
|
|
||||||
|
## 2. Trzy warstwy i jedna zasada
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ KONTEKST — APM (Microsoft Agent Package Manager) │
|
||||||
|
│ apm.yml + .apm/{instructions,skills,prompts,agents,context} │
|
||||||
|
│ Wersjonowany, pinowany lockfile'em, audytowany polityką. │
|
||||||
|
└──────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ apm install → apm_modules/
|
||||||
|
┌──────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ RUNTIME — agno Workflow │
|
||||||
|
│ Kompilacja prymitywów APM → Agent/Skills/Steps. │
|
||||||
|
│ Deterministyczne kroki + sandboxowane narzędzia. │
|
||||||
|
└──────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ agentic-codemod run
|
||||||
|
┌──────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ EGZEKUCJA — GitLab CI │
|
||||||
|
│ Efemeryczny job, bramka manualna, artefakty audytowe, MR. │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Zasada nadrzędna: **kontekst jest artefaktem, nie kodem**. Zmiana zachowania sieci agentowej
|
||||||
|
(nowa reguła migracji, ostrzejszy guardrail, inny model dla roli) to podbicie wersji pakietu APM,
|
||||||
|
a nie merge request do Pythona. Dzięki temu zespół właściciela SDK może dostarczyć wiedzę
|
||||||
|
migracyjną, nie mając dostępu do silnika pipeline'u.
|
||||||
|
|
||||||
|
## 3. Przepływ
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
T[Trigger: issue z labelką / MR / harmonogram / formularz] --> C[apm install + apm audit]
|
||||||
|
C --> I[intake: deterministyczny wsad zadania]
|
||||||
|
I --> R[recon: fakty o repozytorium]
|
||||||
|
R --> P[plan: ChangePlan z kryteriami akceptacji]
|
||||||
|
P --> G{jest co wdrażać?}
|
||||||
|
G -- nie --> M[manifest + komentarz w issue]
|
||||||
|
G -- tak --> B[bump wersji: deterministyczny]
|
||||||
|
B --> L[pętla: implement → verify]
|
||||||
|
L -- czerwono, iteracja < limit --> L
|
||||||
|
L -- zielono --> V[review: recenzja diffa]
|
||||||
|
V --> S[scribe: opis merge requesta]
|
||||||
|
S --> H{bramka manualna w CI}
|
||||||
|
H -- zatwierdzone --> MR[branch + commit + push + MR]
|
||||||
|
H -- nie --> A[artefakty do wglądu, zero zmian zdalnych]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role w sieci
|
||||||
|
|
||||||
|
| Agent | Model | Narzędzia | Wyjście |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `scout` | planner | odczyt, wyszukiwanie | `RepoProfile` |
|
||||||
|
| `planner` | planner | odczyt, wyszukiwanie | `ChangePlan` |
|
||||||
|
| `coder` | coder | odczyt, edycja, weryfikacja | zmieniony kod |
|
||||||
|
| `reviewer` | reviewer | odczyt, diff | `ReviewVerdict` |
|
||||||
|
| `scribe` | scribe | diff | `MergeRequestDraft` |
|
||||||
|
|
||||||
|
Definicje ról leżą w `.apm/agents/*.agent.md`. Kod nie zna nazw agentów - czyta je z kontekstu.
|
||||||
|
|
||||||
|
## 4. Co jest deterministyczne, a co należy do modelu
|
||||||
|
|
||||||
|
To jest najważniejsza decyzja projektowa. Model dostaje wyłącznie tę część pracy,
|
||||||
|
której nie da się zrobić inaczej.
|
||||||
|
|
||||||
|
| Krok | Wykonawca | Dlaczego |
|
||||||
|
|---|---|---|
|
||||||
|
| Wykrycie ekosystemu, plików zależności, komendy testowej | kod (`adapters/`) | jednoznaczne, sprawdzalne |
|
||||||
|
| Podbicie deklaracji wersji | kod (`adapters/`) | zero powodów, by ryzykować halucynację w pliku zależności |
|
||||||
|
| Migracja objęta regułami codemod | kod (`workflow/codemod.py`) | reguły z pakietu APM, w pełni powtarzalne |
|
||||||
|
| Nietypowe użycia API, kontekst biznesowy | model (`coder`) | tu klasyczna automatyzacja się kończy |
|
||||||
|
| Ocena zakresu i ryzyka zmiany | model (`reviewer`) + kontrola mechaniczna | dwa niezależne spojrzenia |
|
||||||
|
| Uruchomienie testów | kod (`tools/verification.py`) | agent nie dostaje powłoki |
|
||||||
|
| Branch, commit, push, MR | kod (`workflow/runner.py`) | jednoznaczny autor i format historii |
|
||||||
|
|
||||||
|
Konsekwencja: pipeline ma **dwa tryby o identycznej topologii**. Tryb `--offline` używa
|
||||||
|
wyłącznie reguł (`RuleBrain`), tryb domyślny dokłada agentów (`LlmBrain`). Tryb offline jest
|
||||||
|
bramką jakości na każdym MR - sprawdza cały przepływ bez kosztu GPU i bez dostępu do modelu.
|
||||||
|
|
||||||
|
## 5. Model bezpieczeństwa
|
||||||
|
|
||||||
|
Guardraile istnieją w dwóch warstwach, bo prompt nie jest zabezpieczeniem.
|
||||||
|
|
||||||
|
**Warstwa promptowa** (`.apm/instructions/security.instructions.md`) - dla modelu:
|
||||||
|
zakaz zmian w plikach pipeline'u, zakaz sekretów, zakaz rozszerzania zakresu,
|
||||||
|
zakaz usuwania testów, zakaz nowych zależności.
|
||||||
|
|
||||||
|
**Warstwa egzekucji** (`tools/workspace.py`, `config.py`) - dla audytora:
|
||||||
|
|
||||||
|
- każda ścieżka rozwiązywana względem korzenia repozytorium; wyjście przez `..` i dowiązania niemożliwe,
|
||||||
|
- lista `DEFAULT_DENY_GLOBS` blokuje zapis do `.gitlab-ci.yml`, `.git/`, `Dockerfile*`, `*.pem`, `.env*`, manifestów APM,
|
||||||
|
- budżety: liczba zmienionych plików, rozmiar pliku, liczba wywołań narzędzi, limit czasu weryfikacji,
|
||||||
|
- agent nie ma powłoki - jedyna operacja wykonawcza to `run_verification` z komendą ustaloną przez adapter,
|
||||||
|
- środowisko weryfikacji jest czyszczone ze zmiennych zawierających `TOKEN`, `SECRET`, `PASSWORD`, `API_KEY`,
|
||||||
|
- każde wywołanie narzędzia trafia do `trace.jsonl` z redakcją sekretów.
|
||||||
|
|
||||||
|
**Warstwa dostawy kontekstu** (`apm-policy.yml`): dozwolone źródła pakietów, zakaz prymitywu
|
||||||
|
`hooks` (wykonuje kod na runnerze), wymagane piny po tagu, wymagany lockfile, `apm audit`
|
||||||
|
wykrywający ręczną edycję zainstalowanego kontekstu.
|
||||||
|
|
||||||
|
## 6. Artefakty przebiegu
|
||||||
|
|
||||||
|
Każde uruchomienie zostawia w `--run-dir` komplet dowodów:
|
||||||
|
|
||||||
|
| Plik | Zawartość |
|
||||||
|
|---|---|
|
||||||
|
| `run.json` | manifest: zadanie, profil, plan, weryfikacje, werdykt, modele, hash lockfile'a APM |
|
||||||
|
| `plan.json` | plan zmian z kryteriami akceptacji i pozycjami `requires_human` |
|
||||||
|
| `profile.json` | ustalone fakty o repozytorium |
|
||||||
|
| `review.json` | werdykt recenzenta z findingami |
|
||||||
|
| `merge_request.md` | tytuł i opis MR |
|
||||||
|
| `changes.patch` | pełny diff |
|
||||||
|
| `trace.jsonl` | każde wywołanie narzędzia z czasem i wynikiem |
|
||||||
|
|
||||||
|
To jest odpowiedź na pytanie audytora "na jakiej podstawie ta zmiana weszła do repozytorium".
|
||||||
|
|
||||||
|
## 7. Backend LLM
|
||||||
|
|
||||||
|
Silnik zna wyłącznie jeden kontrakt: **endpoint zgodny z API OpenAI pod `CODEMOD_LLM_BASE_URL`**.
|
||||||
|
Cała wiedza o dostawcy jest w `llm.py` (fabryka modeli) i `llm/models.yml` (adresy, modele per rola).
|
||||||
|
|
||||||
|
Docelowo jest to vLLM na OpenShifcie. Lokalnie nie ma jednej ścieżki dla obu systemów -
|
||||||
|
Docker na macOS nie ma dostępu do Metala - więc `task llm:up` wykrywa platformę:
|
||||||
|
|
||||||
|
| Backend | Kiedy | Mechanizm |
|
||||||
|
|---|---|---|
|
||||||
|
| `vllm-gpu` | Linux + NVIDIA + Docker | `vllm/vllm-openai` przez docker compose |
|
||||||
|
| `vllm-metal` | macOS Apple Silicon | plugin `vllm-project/vllm-metal` (MLX), natywny `vllm serve` |
|
||||||
|
| `ollama` | awaryjnie, obie platformy | `/v1` zgodne z OpenAI, natywnie lub w kontenerze |
|
||||||
|
| `mock` | testy i CI | `llm/mock_server.py` - stdlib, zero pobierania |
|
||||||
|
|
||||||
|
Każdy backend serwujący realny model wymusza `--enable-auto-tool-choice` z parserem `hermes`.
|
||||||
|
Bez tego vLLM zwraca opis wywołania w treści odpowiedzi zamiast `tool_calls`, agent `coder`
|
||||||
|
nie tknąłby żadnego pliku, a przebieg kończyłby się "sukcesem" bez jednej zmiany - awaria cicha,
|
||||||
|
czyli najgorszy rodzaj.
|
||||||
|
|
||||||
|
### Atrapa jako test double
|
||||||
|
|
||||||
|
`mock` nie jest symulatorem modelu. Odpowiada z góry ustalonymi wywołaniami narzędzi
|
||||||
|
i strukturami zestrojonymi z fixture'em `acme-app`, a rozpoznaje pytającego po nazwie agenta,
|
||||||
|
którą agno wstrzykuje do komunikatu systemowego. Dzięki temu `tests/test_llm_path.py`
|
||||||
|
przepuszcza pełny przebieg przez `LlmBrain` - budowę agentów z definicji APM, tool-calling,
|
||||||
|
parsowanie strukturalnych wyjść i pętlę weryfikacji - na każdym MR, bez GPU i bez sieci.
|
||||||
|
|
||||||
|
Podział odpowiedzialności testów jest więc taki:
|
||||||
|
|
||||||
|
| Co jest sprawdzane | Czym |
|
||||||
|
|---|---|
|
||||||
|
| reguły, adaptery, sandbox, workflow | tryb `--offline` (`RuleBrain`) |
|
||||||
|
| agenci, narzędzia, schematy, pętla | tryb `llm` na atrapie (`LlmBrain` + `mock`) |
|
||||||
|
| jakość migracji na nietypowym kodzie | realny model, ręcznie |
|
||||||
|
|
||||||
|
## 8. Granice rozwiązania
|
||||||
|
|
||||||
|
- **Bez agenta-nadzorcy.** Topologia jest jawnym przepływem (agno `Workflow`), a nie
|
||||||
|
swobodną delegacją między agentami. W zamian za mniejszą elastyczność dostajemy
|
||||||
|
przewidywalny koszt, powtarzalny ślad i możliwość wstawienia bramki między krokami.
|
||||||
|
- **Bez AgentOS.** Runtime to efemeryczny job CI. Nie utrzymujemy usługi z pamięcią sesji -
|
||||||
|
stan przebiegu żyje w artefaktach.
|
||||||
|
- **Bez dostępu do sieci z poziomu agenta.** Cała wiedza migracyjna przychodzi w pakiecie APM.
|
||||||
|
Jeśli notatki migracyjnej nie ma, pipeline zatrzymuje się i zgłasza `requires_human`,
|
||||||
|
zamiast migrować "z pamięci modelu".
|
||||||
|
- **Nie każde repozytorium się nadaje.** Bez działającej komendy testowej weryfikacja nic nie znaczy,
|
||||||
|
a bez weryfikacji ten pipeline nie ma prawa niczego publikować.
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
# Runbook
|
||||||
|
|
||||||
|
## Uruchomienie lokalne
|
||||||
|
|
||||||
|
Zadania uruchamia [go-task](https://taskfile.dev): `brew install go-task` na macOS,
|
||||||
|
`pip install go-task-bin` gdziekolwiek indziej.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task doctor # co jest w środowisku, czego brakuje
|
||||||
|
task install # .venv + zależności (runtime i dev)
|
||||||
|
task test # 46 testów, bez LLM
|
||||||
|
task demo # pełny przebieg offline na fixture
|
||||||
|
task ci # lint + test + demo, czyli to co sprawdza pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
Wynik przebiegu: `.runs/demo/run.json`, `changes.patch`, `merge_request.md`, `trace.jsonl`.
|
||||||
|
|
||||||
|
### macOS - trzy rzeczy, które potrafią zaskoczyć
|
||||||
|
|
||||||
|
1. **`externally-managed-environment` (PEP 668).** Homebrew blokuje `pip install` do interpretera
|
||||||
|
systemowego. Dlatego `task install` zawsze tworzy `.venv` i instaluje tylko do niego.
|
||||||
|
Nigdy nie uruchamiaj `pip install -e .` na `python3` z systemu.
|
||||||
|
2. **Systemowy Python to 3.9.** Za stary. `brew install python@3.12`, potem
|
||||||
|
`PYTHON=python3.12 task install` - `task doctor` powie, jaka wersja jest widoczna.
|
||||||
|
3. **Weryfikacja repozytorium uruchamia się interpreterem z `.venv`** (`sys.executable`),
|
||||||
|
a nie `python3` z `PATH`. Repozytorium docelowe z własnym środowiskiem budowania
|
||||||
|
nadpisuje komendę przez `--verify-command`.
|
||||||
|
|
||||||
|
## Model językowy lokalnie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task llm:list # backendy i ten wykryty dla tej maszyny
|
||||||
|
task llm:up # uruchomienie + czekanie na gotowość endpointu
|
||||||
|
task llm:status # czy odpowiada i jakie modele wystawia
|
||||||
|
task llm:smoke # jedno zapytanie kontrolne
|
||||||
|
task llm:down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logi
|
||||||
|
|
||||||
|
Jedno wejście niezależnie od tego, czy backend siedzi w kontenerze (`vllm-gpu`),
|
||||||
|
czy jest procesem z plikiem logu (`vllm-metal`, `ollama`, `mock`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task llm:logs # na żywo, ostatnie 100 linii kontekstu
|
||||||
|
task llm:logs TAIL=500 # więcej historii
|
||||||
|
task llm:logs FOLLOW=false # jednorazowy zrzut zamiast śledzenia
|
||||||
|
task llm:logs GREP=throughput # filtr (wyrażenie regularne, bez rozróżniania wielkości liter)
|
||||||
|
task llm:logs:errors # tylko błędy i ostrzeżenia
|
||||||
|
task llm:logs:save OUT=/tmp/vllm.log # zrzut do pliku, np. do zgłoszenia
|
||||||
|
```
|
||||||
|
|
||||||
|
Ctrl-C kończy śledzenie normalnie. Pliki logów żyją w `.runs/llm/`.
|
||||||
|
|
||||||
|
Czego szukać w logach vLLM:
|
||||||
|
|
||||||
|
| Linia | Znaczenie |
|
||||||
|
|---|---|
|
||||||
|
| `Downloading ... safetensors` | pobieranie modelu; przy pierwszym starcie to zwykle najdłuższy etap |
|
||||||
|
| `Application startup complete` | serwer gotowy, endpoint odpowiada |
|
||||||
|
| `Avg prompt throughput ... Avg generation throughput` | przebieg pracuje; zera przez dłuższą chwilę oznaczają, że nikt nie pyta |
|
||||||
|
| `GPU KV cache usage` | rosnące do 100% zwiastuje kolejkowanie żądań |
|
||||||
|
| `CUDA out of memory` | zmniejsz `--max-model-len` albo `VLLM_GPU_UTIL`, albo weź mniejszy model |
|
||||||
|
| `ValueError: ... tool call parser` | zła wartość `--tool-call-parser` dla tego modelu |
|
||||||
|
|
||||||
|
Przy atrapie (`mock`) log pokazuje przebieg rozmowy z agentami - po jednej linii
|
||||||
|
na żądanie, z nazwą roli i wywoływanym narzędziem. To najszybszy sposób, żeby
|
||||||
|
zobaczyć, w którym kroku pipeline utknął.
|
||||||
|
|
||||||
|
| Backend | Warunki | Uwagi |
|
||||||
|
|---|---|---|
|
||||||
|
| `vllm-gpu` | Linux, GPU NVIDIA, Docker | pierwsze uruchomienie pobiera model (kilka GB), `start_period` w healthchecku to 5 minut |
|
||||||
|
| `vllm-metal` | macOS Apple Silicon, arm64 Python 3.12, Xcode CLT | plugin instaluje się do `~/.venv-vllm-metal` przy pierwszym `task llm:up` |
|
||||||
|
| `ollama` | `ollama` w PATH albo Docker | wariant awaryjny; na macOS instaluj natywnie, w kontenerze liczy na CPU |
|
||||||
|
| `mock` | nic | atrapa z `llm/mock_server.py`, deterministyczna, zestrojona z fixture'em |
|
||||||
|
|
||||||
|
Wybór ręczny: `LLM_BACKEND=ollama task llm:up`. Konfiguracja: `llm/models.yml`.
|
||||||
|
|
||||||
|
### Zdalny endpoint (np. vLLM na OpenShifcie)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CODEMOD_MODEL_PROVIDER=vllm
|
||||||
|
export CODEMOD_LLM_BASE_URL=https://vllm.apps.ocp.internal/v1
|
||||||
|
export CODEMOD_LLM_API_KEY=... # w CI: zmienna masked
|
||||||
|
export CODEMOD_MODEL_CODER=Qwen/Qwen3-Coder-30B
|
||||||
|
```
|
||||||
|
|
||||||
|
## Przebieg z modelem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(task llm:env)" # przy backendzie lokalnym
|
||||||
|
|
||||||
|
task run -- \
|
||||||
|
--repo /sciezka/do/repo \
|
||||||
|
--package acme-sdk --module acme --to-version 2.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Najpierw `task plan -- --repo ... --package ... --to-version ...`. Plan czyta się szybciej niż diff.
|
||||||
|
|
||||||
|
## Kody wyjścia
|
||||||
|
|
||||||
|
| Kod | Status | Znaczenie |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | `success` / `no_changes` | zmiana gotowa albo nie było czego zmieniać |
|
||||||
|
| 1 | `failed` | weryfikacja czerwona lub błąd przebiegu |
|
||||||
|
| 3 | `blocked` | recenzent zgłosił problem blokujący - wymagana decyzja człowieka |
|
||||||
|
|
||||||
|
## Wyzwalanie w GitLabie
|
||||||
|
|
||||||
|
1. **Formularz** - Build > Pipelines > Run pipeline, pola `CODEMOD_PACKAGE`, `CODEMOD_TO_VERSION`.
|
||||||
|
2. **Issue z labelką** - webhook na zdarzenie issue → trigger token → pipeline ze zmiennymi z tytułu issue.
|
||||||
|
3. **Harmonogram** - audyt zależności (`--prompt dependency-audit --plan-only`) raz w tygodniu.
|
||||||
|
4. **Include w repozytorium docelowym** - patrz `.gitlab/ci/agentic-codemod.template.yml`.
|
||||||
|
|
||||||
|
Job `codemod:apply` jest **manualny**. To jest bramka, nie niedopatrzenie.
|
||||||
|
|
||||||
|
## Typowe sytuacje
|
||||||
|
|
||||||
|
**`EcosystemNotDetected`** - repozytorium nie ma pliku markerowego (`pyproject.toml`, `pom.xml`,
|
||||||
|
`package.json`). Dopisz adapter w `adapters/` albo wskaż komendę weryfikacji przez `--verify-command`.
|
||||||
|
|
||||||
|
**`Brak skilli w kontekście APM`** - nie wykonano `apm install` albo pakiet nie jest zadeklarowany
|
||||||
|
w `apm.yml`. Job `codemod:context` jest bramką właśnie na taką sytuację.
|
||||||
|
|
||||||
|
**Status `blocked`** - przeczytaj `review.json`. Findingi `severity: blocker` opisują konkretny problem;
|
||||||
|
`requires_human` w `plan.json` mówi, czego agent świadomie nie ruszył.
|
||||||
|
|
||||||
|
**Agent nie wywołuje żadnych narzędzi, tylko opisuje co by zrobił** - serwer nie ma włączonego
|
||||||
|
tool-callingu. vLLM wymaga `--enable-auto-tool-choice --tool-call-parser hermes` (jest w `llm/models.yml`);
|
||||||
|
przy zdalnym endpointcie sprawdź to po stronie wdrożenia.
|
||||||
|
|
||||||
|
**`task llm:up` kończy się timeoutem** - pobieranie modelu trwa dłużej niż `LLM_WAIT_TIMEOUT`.
|
||||||
|
Zajrzyj do `.runs/llm/*.log` albo `task llm:logs`; zwiększ limit: `LLM_WAIT_TIMEOUT=1800 task llm:up`.
|
||||||
|
|
||||||
|
**macOS: `vllm-metal` nie chce się zainstalować** - wymaga natywnego arm64 Pythona 3.12
|
||||||
|
(Rosetta nie wystarczy) i Xcode Command Line Tools (`xcode-select --install`).
|
||||||
|
Wariant awaryjny: `LLM_BACKEND=ollama task llm:up`.
|
||||||
|
|
||||||
|
**Weryfikacja czerwona po limicie iteracji** - `changes.patch` zawiera stan po ostatniej próbie.
|
||||||
|
Zwykle oznacza, że migracja wykracza poza wiedzę z pakietu APM. Uzupełnij notatkę migracyjną
|
||||||
|
i regułę codemod, zamiast podnosić limit iteracji.
|
||||||
|
|
||||||
|
**Nie ma deklaracji pakietu do podbicia** - `profile.json` w polu `gaps`. Sprawdź, czy wersja nie jest
|
||||||
|
pinowana w pliku lock albo w obrazie bazowym.
|
||||||
|
|
||||||
|
## Dostrajanie budżetów
|
||||||
|
|
||||||
|
| Zmienna | Domyślnie | Kiedy zmieniać |
|
||||||
|
|---|---|---|
|
||||||
|
| `CODEMOD_MAX_ITERATIONS` | 4 | rzadko - wysoki limit maskuje braki w kontekście |
|
||||||
|
| `CODEMOD_MAX_FILES_CHANGED` | 40 | monorepo z rozlanymi użyciami |
|
||||||
|
| `CODEMOD_TOOL_CALL_LIMIT` | 60 | duże repozytoria wymagające dużo rozpoznania |
|
||||||
|
| `CODEMOD_VERIFY_TIMEOUT_S` | 900 | wolne buildy (Maven, testy integracyjne) |
|
||||||
|
|
||||||
|
## Zmiana modelu
|
||||||
|
|
||||||
|
Modele per rola są w `llm/models.yml`, sekcja `models` danego backendu. Kolejność
|
||||||
|
inwestowania w większy model: `coder` (stabilne tool-calling), potem `planner`
|
||||||
|
(rozumowanie o zakresie), na końcu `reviewer`. `scribe` może zostać najmniejszy.
|
||||||
|
|
||||||
|
Po zmianie: `eval "$(task llm:env)"` w bieżącej powłoce.
|
||||||
|
|
||||||
|
## Dodanie nowego ekosystemu
|
||||||
|
|
||||||
|
1. Nowa klasa w `adapters/` dziedzicząca po `EcosystemAdapter` (5 metod).
|
||||||
|
2. Rejestracja w `adapters/__init__.py`.
|
||||||
|
3. Test w `test_adapters.py` na odczyt i podbicie wersji (`task test`).
|
||||||
|
4. Opcjonalnie: instrukcja APM z konwencjami języka (`.apm/instructions/`).
|
||||||
|
|
||||||
|
Warstwa agentowa nie wymaga zmian.
|
||||||
|
|
||||||
|
## Dodanie migracji nowego SDK
|
||||||
|
|
||||||
|
1. Notatka migracyjna dla człowieka i modelu: `references/<pakiet>-<wersja>-migration.md`.
|
||||||
|
2. Reguły maszynowe: `references/<pakiet>-<wersja>-migration.codemod.yaml`.
|
||||||
|
3. Publikacja jako pakiet APM z tagiem, dopisanie do `apm.yml` repozytoriów docelowych.
|
||||||
|
|
||||||
|
Nic w Pythonie się nie zmienia.
|
||||||
@@ -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.
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "acme-app"
|
||||||
|
version = "0.3.0"
|
||||||
|
description = "Przykładowa aplikacja korzystająca z acme-sdk 1.x - fixture dla pipeline'u agentowego"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"acme-sdk==1.4.2",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Przykładowa aplikacja korzystająca z acme-sdk."""
|
||||||
|
|
||||||
|
__all__ = ["notifier"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Wysyłka powiadomień do klientów przez acme-sdk."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from acme import Client
|
||||||
|
from acme.errors import AcmeError
|
||||||
|
|
||||||
|
WELCOME_BODY = "Witamy w serwisie. Twoje konto jest aktywne."
|
||||||
|
|
||||||
|
|
||||||
|
def send_welcome(email: str, api_key: str, endpoint: str) -> str:
|
||||||
|
"""Wysyła powiadomienie powitalne i zwraca identyfikator wiadomości."""
|
||||||
|
client = Client(api_key=api_key, endpoint=endpoint)
|
||||||
|
result = client.send(to=email, body=WELCOME_BODY)
|
||||||
|
client.close()
|
||||||
|
return result["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def send_reminder(email: str, api_key: str, endpoint: str, body: str) -> str | None:
|
||||||
|
"""Wysyła przypomnienie. Zwraca None, gdy dostawca odrzucił wiadomość."""
|
||||||
|
client = Client(api_key=api_key, endpoint=endpoint)
|
||||||
|
try:
|
||||||
|
result = client.send(to=email, body=body)
|
||||||
|
except AcmeError:
|
||||||
|
return None
|
||||||
|
client.close()
|
||||||
|
return result["id"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Atrapa acme-sdk 2.1.0 - wyłącznie API z wersji 2.x.
|
||||||
|
|
||||||
|
Odpowiada SDK dostarczanemu przez dostawcę: klasa `Client` i metoda `send()` z 1.x
|
||||||
|
zostały usunięte, więc kod sprzed migracji nie zaimportuje się ani nie zadziała.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .errors import AcmeError
|
||||||
|
|
||||||
|
__version__ = "2.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Message:
|
||||||
|
id: str
|
||||||
|
recipient: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class _Messages:
|
||||||
|
def __init__(self, client: "AcmeClient") -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def create(self, recipient: str, content: str) -> Message:
|
||||||
|
if recipient.startswith("odrzuc@"):
|
||||||
|
raise AcmeError("recipient rejected by provider")
|
||||||
|
return Message(id=f"msg_{abs(hash((recipient, content))) % 10**8:08d}", recipient=recipient, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
class AcmeClient:
|
||||||
|
"""Klient 2.x. Zasoby zwalniane automatycznie - brak metody close()."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, base_url: str, timeout: float = 10.0) -> None:
|
||||||
|
if not api_key:
|
||||||
|
raise AcmeError("api_key is required")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = base_url
|
||||||
|
self.timeout = timeout
|
||||||
|
self.messages = _Messages(self)
|
||||||
|
|
||||||
|
def __enter__(self) -> "AcmeClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc_info: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AcmeClient", "AcmeError", "Message", "__version__"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Hierarchia błędów acme-sdk - niezmieniona między 1.x a 2.x."""
|
||||||
|
|
||||||
|
|
||||||
|
class AcmeError(Exception):
|
||||||
|
"""Błąd zwrócony przez dostawcę."""
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from acme_app.notifier import send_reminder, send_welcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_welcome_zwraca_identyfikator():
|
||||||
|
message_id = send_welcome("jan@example.com", api_key="k-1", endpoint="https://acme.internal")
|
||||||
|
assert message_id.startswith("msg_")
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_reminder_zwraca_identyfikator():
|
||||||
|
message_id = send_reminder("jan@example.com", api_key="k-1", endpoint="https://acme.internal", body="Przypomnienie")
|
||||||
|
assert message_id.startswith("msg_")
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_reminder_zwraca_none_przy_bledzie_dostawcy():
|
||||||
|
assert send_reminder("odrzuc@example.com", api_key="k-1", endpoint="https://acme.internal", body="x") is None
|
||||||
@@ -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:
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Executable
+141
@@ -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())
|
||||||
Executable
+80
@@ -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())
|
||||||
Executable
+108
@@ -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
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# vLLM na Apple Silicon przez plugin vllm-project/vllm-metal (backend MLX).
|
||||||
|
#
|
||||||
|
# Docker na macOS nie ma dostępu do Metala, więc to jedyna ścieżka z akceleracją.
|
||||||
|
# Plugin instaluje się do własnego venva (~/.venv-vllm-metal) i wymaga natywnego
|
||||||
|
# arm64 Pythona 3.12 oraz Xcode Command Line Tools.
|
||||||
|
#
|
||||||
|
# Użycie: vllm-metal.sh up <model> <port> <log> <pid> | down <pid> | install
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VENV="${VLLM_METAL_VENV:-$HOME/.venv-vllm-metal}"
|
||||||
|
INSTALLER_URL="https://raw.githubusercontent.com/vllm-project/vllm-metal/main/install.sh"
|
||||||
|
|
||||||
|
fail() { echo "błąd: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
check_platform() {
|
||||||
|
[ "$(uname -s)" = "Darwin" ] || fail "ten backend działa wyłącznie na macOS (wykryto $(uname -s))"
|
||||||
|
[ "$(uname -m)" = "arm64" ] || fail "wymagany Apple Silicon (wykryto $(uname -m))"
|
||||||
|
xcode-select -p >/dev/null 2>&1 || fail "brak Xcode Command Line Tools - uruchom: xcode-select --install"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_plugin() {
|
||||||
|
check_platform
|
||||||
|
if [ -x "$VENV/bin/vllm" ]; then
|
||||||
|
echo "vllm-metal już zainstalowany w $VENV"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "instaluję vllm-metal do $VENV (to potrwa kilka minut)..."
|
||||||
|
curl -fsSL "$INSTALLER_URL" | bash
|
||||||
|
[ -x "$VENV/bin/vllm" ] || fail "instalacja nie powiodła się - sprawdź https://github.com/vllm-project/vllm-metal"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
install)
|
||||||
|
install_plugin
|
||||||
|
;;
|
||||||
|
up)
|
||||||
|
model="${2:?podaj model}"; port="${3:-8000}"; log="${4:-.runs/llm/vllm-metal.log}"; pidfile="${5:-.runs/llm/vllm-metal.pid}"
|
||||||
|
install_plugin
|
||||||
|
mkdir -p "$(dirname "$log")" "$(dirname "$pidfile")"
|
||||||
|
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
|
||||||
|
echo "vllm-metal już działa (pid $(cat "$pidfile"))"; exit 0
|
||||||
|
fi
|
||||||
|
echo "startuję vllm serve $model na porcie $port (log: $log)"
|
||||||
|
python3 "$(dirname "$0")/daemonize.py" --pidfile "$pidfile" --log "$log" -- \
|
||||||
|
"$VENV/bin/vllm" serve "$model" \
|
||||||
|
--port "$port" \
|
||||||
|
--max-model-len 16384 \
|
||||||
|
--enable-auto-tool-choice \
|
||||||
|
--tool-call-parser hermes
|
||||||
|
echo "pierwsze uruchomienie pobiera model - to może potrwać (log: $log)"
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
pidfile="${2:-.runs/llm/vllm-metal.pid}"
|
||||||
|
python3 "$(dirname "$0")/daemonize.py" --pidfile "$pidfile" --log /dev/null --stop
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "użycie: $0 {install|up <model> <port> <log> <pid>|down <pid>}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+22
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .cli import app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Ekosystem Java/Maven - wersje trzymane w properties lub w <dependency>.
|
||||||
|
|
||||||
|
Status: adapter referencyjny (bez fixture w tym repozytorium). Pokazuje, jak dołożyć
|
||||||
|
kolejny ekosystem bez dotykania warstwy agentowej.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .base import EcosystemAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class MavenAdapter(EcosystemAdapter):
|
||||||
|
name = "java-maven"
|
||||||
|
marker_files = ("pom.xml",)
|
||||||
|
|
||||||
|
def dependency_files(self) -> list[Path]:
|
||||||
|
return [p for p in [self.root / "pom.xml", *sorted(self.root.glob("*/pom.xml"))] if p.exists()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coords(package: str) -> tuple[str, str]:
|
||||||
|
if ":" not in package:
|
||||||
|
raise ValueError("Dla Mavena podaj koordynaty w formacie groupId:artifactId")
|
||||||
|
group, artifact = package.split(":", 1)
|
||||||
|
return group, artifact
|
||||||
|
|
||||||
|
def _dependency_pattern(self, package: str) -> re.Pattern[str]:
|
||||||
|
group, artifact = self._coords(package)
|
||||||
|
return re.compile(
|
||||||
|
rf"(<groupId>\s*{re.escape(group)}\s*</groupId>\s*"
|
||||||
|
rf"<artifactId>\s*{re.escape(artifact)}\s*</artifactId>\s*"
|
||||||
|
rf"<version>)(?P<version>[^<]+)(</version>)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_declared_version(self, package: str) -> str | None:
|
||||||
|
pattern = self._dependency_pattern(package)
|
||||||
|
for path in self.dependency_files():
|
||||||
|
match = pattern.search(path.read_text(encoding="utf-8"))
|
||||||
|
if match:
|
||||||
|
return match.group("version").strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_declared_version(self, package: str, version: str) -> list[Path]:
|
||||||
|
pattern = self._dependency_pattern(package)
|
||||||
|
changed: list[Path] = []
|
||||||
|
for path in self.dependency_files():
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
updated = pattern.sub(rf"\g<1>{version}\g<3>", original)
|
||||||
|
if updated != original:
|
||||||
|
path.write_text(updated, encoding="utf-8")
|
||||||
|
changed.append(path)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def module_name(self, package: str) -> str:
|
||||||
|
group, _ = self._coords(package)
|
||||||
|
return group
|
||||||
|
|
||||||
|
def verify_command(self) -> list[str]:
|
||||||
|
return ["mvn", "-B", "-o", "verify"]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Ekosystem Node/npm - wersje w package.json.
|
||||||
|
|
||||||
|
Status: adapter referencyjny (bez fixture w tym repozytorium).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .base import EcosystemAdapter
|
||||||
|
|
||||||
|
_SECTIONS = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies")
|
||||||
|
|
||||||
|
|
||||||
|
class NodeNpmAdapter(EcosystemAdapter):
|
||||||
|
name = "node-npm"
|
||||||
|
marker_files = ("package.json",)
|
||||||
|
|
||||||
|
def dependency_files(self) -> list[Path]:
|
||||||
|
return [p for p in [self.root / "package.json"] if p.exists()]
|
||||||
|
|
||||||
|
def read_declared_version(self, package: str) -> str | None:
|
||||||
|
for path in self.dependency_files():
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
for section in _SECTIONS:
|
||||||
|
value = (data.get(section) or {}).get(package)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_declared_version(self, package: str, version: str) -> list[Path]:
|
||||||
|
changed: list[Path] = []
|
||||||
|
for path in self.dependency_files():
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
data = json.loads(original)
|
||||||
|
updated = original
|
||||||
|
for section in _SECTIONS:
|
||||||
|
current = (data.get(section) or {}).get(package)
|
||||||
|
if not current:
|
||||||
|
continue
|
||||||
|
prefix = re.match(r"^[\^~]?", str(current)).group(0)
|
||||||
|
pattern = re.compile(rf'("{re.escape(package)}"\s*:\s*")[^"]+(")')
|
||||||
|
updated = pattern.sub(rf"\g<1>{prefix}{version}\g<2>", updated)
|
||||||
|
if updated != original:
|
||||||
|
path.write_text(updated, encoding="utf-8")
|
||||||
|
changed.append(path)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def module_name(self, package: str) -> str:
|
||||||
|
return package
|
||||||
|
|
||||||
|
def verify_command(self) -> list[str]:
|
||||||
|
return ["npm", "test", "--silent"]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Ekosystem Python: pyproject.toml, requirements*.txt, constraints.txt."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .base import EcosystemAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def _requirement_pattern(package: str) -> re.Pattern[str]:
|
||||||
|
"""Dopasowuje deklarację zależności: acme-sdk==1.4.2, acme_sdk >= 1.4, "acme-sdk~=1.4.2".
|
||||||
|
|
||||||
|
Operator jest WYMAGANY. Bez tego wzorzec łapie też prozę ("acme-sdk 1.x" w opisie
|
||||||
|
projektu) i pipeline modyfikuje tekst, którego nikt go nie prosił o zmianę -
|
||||||
|
dokładnie ten rodzaj cichego rozszerzenia zakresu, którego zakazują guardraile.
|
||||||
|
"""
|
||||||
|
name = re.escape(package).replace(r"\-", "[-_]")
|
||||||
|
return re.compile(
|
||||||
|
rf"(?P<name>\b{name}\b)(?P<space>\s*)(?P<op>==|>=|<=|~=|!=|>|<)(?P<space2>\s*)(?P<version>[0-9][^\s,\"'\]]*)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PythonPipAdapter(EcosystemAdapter):
|
||||||
|
name = "python-pip"
|
||||||
|
marker_files = ("pyproject.toml", "requirements.txt", "setup.cfg", "setup.py")
|
||||||
|
|
||||||
|
_candidates = ("pyproject.toml", "requirements.txt", "requirements-dev.txt", "constraints.txt", "setup.cfg")
|
||||||
|
|
||||||
|
def dependency_files(self) -> list[Path]:
|
||||||
|
files = [self.root / name for name in self._candidates]
|
||||||
|
files.extend(sorted(self.root.glob("requirements/*.txt")))
|
||||||
|
return [f for f in files if f.exists()]
|
||||||
|
|
||||||
|
def read_declared_version(self, package: str) -> str | None:
|
||||||
|
pattern = _requirement_pattern(package)
|
||||||
|
for path in self.dependency_files():
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match and match.group("version"):
|
||||||
|
return f"{match.group('op') or ''}{match.group('version')}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_declared_version(self, package: str, version: str) -> list[Path]:
|
||||||
|
pattern = _requirement_pattern(package)
|
||||||
|
changed: list[Path] = []
|
||||||
|
for path in self.dependency_files():
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
lines = original.splitlines(keepends=True)
|
||||||
|
updated: list[str] = []
|
||||||
|
touched = False
|
||||||
|
for line in lines:
|
||||||
|
if line.strip().startswith("#") or not pattern.search(line):
|
||||||
|
updated.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _sub(match: re.Match[str]) -> str:
|
||||||
|
# zachowujemy oryginalny operator i odstępy - diff ma pokazywać
|
||||||
|
# wyłącznie zmianę wersji, nie przeformatowanie linii
|
||||||
|
return (
|
||||||
|
f"{match.group('name')}{match.group('space')}{match.group('op')}"
|
||||||
|
f"{match.group('space2')}{version}"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_line = pattern.sub(_sub, line)
|
||||||
|
touched = touched or new_line != line
|
||||||
|
updated.append(new_line)
|
||||||
|
if touched:
|
||||||
|
path.write_text("".join(updated), encoding="utf-8")
|
||||||
|
changed.append(path)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def verify_command(self) -> list[str]:
|
||||||
|
"""Weryfikacja tym samym interpreterem, który uruchamia pipeline.
|
||||||
|
|
||||||
|
Nie `python3` z PATH: przy uruchomieniu z venva (a tak działa Taskfile i obraz CI)
|
||||||
|
systemowy `python3` na macOS nie ma pytesta i weryfikacja byłaby czerwona
|
||||||
|
z powodu środowiska, a nie z powodu kodu. Repozytorium z własnym środowiskiem
|
||||||
|
budowania nadpisuje komendę przez `--verify-command`.
|
||||||
|
"""
|
||||||
|
return [sys.executable or "python3", "-m", "pytest", "-q"]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Odkrywanie kontekstu agentowego dostarczonego przez APM.
|
||||||
|
|
||||||
|
APM instaluje zależności do `apm_modules/<pakiet>/`, a własne prymitywy repozytorium
|
||||||
|
leżą w `.apm/`. Loader traktuje oba źródła jednolicie: prymitywy lokalne mają
|
||||||
|
pierwszeństwo przed zainstalowanymi (możliwość nadpisania skilla organizacji lokalnie).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .primitives import AgentPrimitive, InstructionPrimitive, PromptPrimitive, SkillRef, parse_frontmatter
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ApmContext:
|
||||||
|
root: Path
|
||||||
|
instructions: dict[str, InstructionPrimitive] = field(default_factory=dict)
|
||||||
|
prompts: dict[str, PromptPrimitive] = field(default_factory=dict)
|
||||||
|
agents: dict[str, AgentPrimitive] = field(default_factory=dict)
|
||||||
|
skills: dict[str, SkillRef] = field(default_factory=dict)
|
||||||
|
context_fragments: dict[str, str] = field(default_factory=dict)
|
||||||
|
packages: list[str] = field(default_factory=list)
|
||||||
|
lockfile_hash: str | None = None
|
||||||
|
|
||||||
|
# -- dostęp ------------------------------------------------------------
|
||||||
|
def prompt(self, name: str) -> PromptPrimitive:
|
||||||
|
if name not in self.prompts:
|
||||||
|
raise KeyError(f"Brak promptu '{name}' w kontekście APM. Dostępne: {sorted(self.prompts)}")
|
||||||
|
return self.prompts[name]
|
||||||
|
|
||||||
|
def agent(self, name: str) -> AgentPrimitive:
|
||||||
|
if name not in self.agents:
|
||||||
|
raise KeyError(f"Brak definicji agenta '{name}' w kontekście APM. Dostępne: {sorted(self.agents)}")
|
||||||
|
return self.agents[name]
|
||||||
|
|
||||||
|
def skill_paths(self, names: list[str]) -> list[Path]:
|
||||||
|
missing = [n for n in names if n not in self.skills]
|
||||||
|
if missing:
|
||||||
|
raise KeyError(f"Brak skilli w kontekście APM: {', '.join(missing)}. Uruchom `apm install`.")
|
||||||
|
return [self.skills[n].path for n in names]
|
||||||
|
|
||||||
|
def instruction_bodies(self, names: list[str]) -> list[str]:
|
||||||
|
missing = [n for n in names if n not in self.instructions]
|
||||||
|
if missing:
|
||||||
|
raise KeyError(f"Brak instrukcji w kontekście APM: {', '.join(missing)}")
|
||||||
|
return [self.instructions[n].body for n in names]
|
||||||
|
|
||||||
|
def context_bodies(self, names: list[str]) -> list[str]:
|
||||||
|
return [self.context_fragments[n] for n in names if n in self.context_fragments]
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, object]:
|
||||||
|
"""Wpis do manifestu przebiegu - z czego dokładnie zbudowano kontekst."""
|
||||||
|
return {
|
||||||
|
"root": str(self.root),
|
||||||
|
"packages": self.packages,
|
||||||
|
"lockfile_hash": self.lockfile_hash,
|
||||||
|
"skills": sorted(self.skills),
|
||||||
|
"instructions": sorted(self.instructions),
|
||||||
|
"prompts": sorted(self.prompts),
|
||||||
|
"agents": sorted(self.agents),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dir(base: Path, package: str, ctx: ApmContext) -> None:
|
||||||
|
apm_dir = base / ".apm"
|
||||||
|
if not apm_dir.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
for path in sorted((apm_dir / "instructions").glob("*.md")):
|
||||||
|
prim = InstructionPrimitive.from_file(path, package)
|
||||||
|
ctx.instructions.setdefault(prim.name, prim)
|
||||||
|
|
||||||
|
for path in sorted((apm_dir / "prompts").glob("*.md")):
|
||||||
|
prim = PromptPrimitive.from_file(path, package)
|
||||||
|
ctx.prompts.setdefault(prim.name, prim)
|
||||||
|
|
||||||
|
for path in sorted((apm_dir / "agents").glob("*.md")):
|
||||||
|
prim = AgentPrimitive.from_file(path, package)
|
||||||
|
ctx.agents.setdefault(prim.name, prim)
|
||||||
|
|
||||||
|
for path in sorted((apm_dir / "context").glob("*.md")):
|
||||||
|
_, body = parse_frontmatter(path.read_text(encoding="utf-8"))
|
||||||
|
ctx.context_fragments.setdefault(path.stem, body)
|
||||||
|
|
||||||
|
skills_dir = apm_dir / "skills"
|
||||||
|
if skills_dir.is_dir():
|
||||||
|
for folder in sorted(p for p in skills_dir.iterdir() if p.is_dir()):
|
||||||
|
skill_md = folder / "SKILL.md"
|
||||||
|
if not skill_md.exists():
|
||||||
|
continue
|
||||||
|
meta, _ = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
|
||||||
|
name = str(meta.get("name") or folder.name)
|
||||||
|
ctx.skills.setdefault(
|
||||||
|
name, SkillRef(name=name, path=folder, description=str(meta.get("description", "")), package=package)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_apm_context(root: Path | str = ".") -> ApmContext:
|
||||||
|
"""Buduje kontekst z `.apm/` repozytorium oraz z zainstalowanych `apm_modules/`.
|
||||||
|
|
||||||
|
Kolejność ma znaczenie: najpierw lokalne (wygrywają przy konflikcie nazw),
|
||||||
|
potem zainstalowane pakiety.
|
||||||
|
"""
|
||||||
|
root = Path(root).resolve()
|
||||||
|
ctx = ApmContext(root=root)
|
||||||
|
|
||||||
|
_load_dir(root, package="local", ctx=ctx)
|
||||||
|
|
||||||
|
modules = root / "apm_modules"
|
||||||
|
if modules.is_dir():
|
||||||
|
for pkg_dir in sorted(p for p in modules.rglob("*") if (p / ".apm").is_dir()):
|
||||||
|
_load_dir(pkg_dir, package=str(pkg_dir.relative_to(modules)), ctx=ctx)
|
||||||
|
ctx.packages.append(str(pkg_dir.relative_to(modules)))
|
||||||
|
|
||||||
|
lock = root / "apm.lock.yaml"
|
||||||
|
if lock.exists():
|
||||||
|
raw = lock.read_bytes()
|
||||||
|
ctx.lockfile_hash = hashlib.sha256(raw).hexdigest()[:16]
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(raw) or {}
|
||||||
|
deps = data.get("dependencies") or {}
|
||||||
|
if isinstance(deps, dict):
|
||||||
|
ctx.packages = sorted({*ctx.packages, *deps.keys()})
|
||||||
|
except yaml.YAMLError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not ctx.agents:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Nie znaleziono definicji agentów w {root}/.apm/agents. "
|
||||||
|
"Sieć agentowa jest konfigurowana przez APM - bez kontekstu nie ma czego uruchomić."
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
@@ -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"
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Konfiguracja przebiegu - wyłącznie ze zmiennych środowiskowych.
|
||||||
|
|
||||||
|
W GitLab CI zmienne pochodzą z ustawień projektu/grupy (masked + protected).
|
||||||
|
Kod nigdy nie czyta sekretów z plików w repozytorium.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ścieżki, których agent nie może dotknąć niezależnie od treści planu.
|
||||||
|
# To ostatnia linia obrony - nie polegamy na tym, że model przeczyta guardraile.
|
||||||
|
DEFAULT_DENY_GLOBS: tuple[str, ...] = (
|
||||||
|
".git/**",
|
||||||
|
".gitlab-ci.yml",
|
||||||
|
".gitlab/**",
|
||||||
|
".github/workflows/**",
|
||||||
|
"Dockerfile*",
|
||||||
|
"**/Chart.yaml",
|
||||||
|
"**/*.pem",
|
||||||
|
"**/*.key",
|
||||||
|
"**/*secret*",
|
||||||
|
".env",
|
||||||
|
".env.*",
|
||||||
|
"apm-policy.yml",
|
||||||
|
"apm.lock.yaml",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, default: str = "") -> str:
|
||||||
|
return os.environ.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_env(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.environ.get(name, default))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelProfiles:
|
||||||
|
"""Model per rola - planista może być większy niż redaktor opisu MR."""
|
||||||
|
|
||||||
|
planner: str = field(default_factory=lambda: _env("CODEMOD_MODEL_PLANNER", "Qwen/Qwen3-32B"))
|
||||||
|
coder: str = field(default_factory=lambda: _env("CODEMOD_MODEL_CODER", "Qwen/Qwen3-Coder-30B"))
|
||||||
|
reviewer: str = field(default_factory=lambda: _env("CODEMOD_MODEL_REVIEWER", "Qwen/Qwen3-32B"))
|
||||||
|
scribe: str = field(default_factory=lambda: _env("CODEMOD_MODEL_SCRIBE", "Qwen/Qwen3-8B"))
|
||||||
|
|
||||||
|
def get(self, profile: str) -> str:
|
||||||
|
return getattr(self, profile, self.planner)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return {"planner": self.planner, "coder": self.coder, "reviewer": self.reviewer, "scribe": self.scribe}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Settings:
|
||||||
|
# --- backend LLM ---
|
||||||
|
provider: str = field(default_factory=lambda: _env("CODEMOD_MODEL_PROVIDER", "vllm"))
|
||||||
|
base_url: str = field(default_factory=lambda: _env("CODEMOD_LLM_BASE_URL", "http://localhost:8000/v1"))
|
||||||
|
api_key: str = field(default_factory=lambda: _env("CODEMOD_LLM_API_KEY", "not-required"))
|
||||||
|
models: ModelProfiles = field(default_factory=ModelProfiles)
|
||||||
|
|
||||||
|
# --- budżety i guardraile ---
|
||||||
|
max_iterations: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_ITERATIONS", 4))
|
||||||
|
max_files_changed: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_FILES_CHANGED", 40))
|
||||||
|
max_file_bytes: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_FILE_BYTES", 400_000))
|
||||||
|
tool_call_limit: int = field(default_factory=lambda: _int_env("CODEMOD_TOOL_CALL_LIMIT", 60))
|
||||||
|
verify_timeout_s: int = field(default_factory=lambda: _int_env("CODEMOD_VERIFY_TIMEOUT_S", 900))
|
||||||
|
deny_globs: tuple[str, ...] = DEFAULT_DENY_GLOBS
|
||||||
|
|
||||||
|
# --- GitLab ---
|
||||||
|
gitlab_url: str = field(default_factory=lambda: _env("CI_SERVER_URL", ""))
|
||||||
|
gitlab_project_id: str = field(default_factory=lambda: _env("CI_PROJECT_ID", ""))
|
||||||
|
gitlab_token: str = field(default_factory=lambda: _env("CODEMOD_GITLAB_TOKEN", ""))
|
||||||
|
|
||||||
|
# --- ścieżki ---
|
||||||
|
apm_root: Path = field(default_factory=lambda: Path(_env("CODEMOD_APM_ROOT", ".")).resolve())
|
||||||
|
run_dir: Path = field(default_factory=lambda: Path(_env("CODEMOD_RUN_DIR", ".runs/current")))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> Settings:
|
||||||
|
return cls()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Minimalny klient GitLaba - tylko to, czego pipeline naprawdę potrzebuje.
|
||||||
|
|
||||||
|
Świadomie bez `python-gitlab`: mniejsza powierzchnia zależności w obrazie runnera
|
||||||
|
i pełna kontrola nad tym, jakie wywołania API wykonuje job modyfikujący kod.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MergeRequestRef:
|
||||||
|
iid: int | None
|
||||||
|
web_url: str | None
|
||||||
|
created: bool
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class GitLabClient:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.enabled = bool(settings.gitlab_url and settings.gitlab_project_id and settings.gitlab_token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _api(self) -> str:
|
||||||
|
return f"{self.settings.gitlab_url.rstrip('/')}/api/v4"
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {"PRIVATE-TOKEN": self.settings.gitlab_token, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
def create_merge_request(
|
||||||
|
self,
|
||||||
|
source_branch: str,
|
||||||
|
target_branch: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
labels: list[str] | None = None,
|
||||||
|
remove_source_branch: bool = True,
|
||||||
|
) -> MergeRequestRef:
|
||||||
|
if not self.enabled:
|
||||||
|
return MergeRequestRef(None, None, False, "Brak konfiguracji GitLaba - pominięto tworzenie MR.")
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"source_branch": source_branch,
|
||||||
|
"target_branch": target_branch,
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"labels": ",".join(labels or ["agentic-codemod"]),
|
||||||
|
"remove_source_branch": remove_source_branch,
|
||||||
|
"squash": True,
|
||||||
|
}
|
||||||
|
response = httpx.post(
|
||||||
|
f"{self._api}/projects/{self.settings.gitlab_project_id}/merge_requests",
|
||||||
|
headers=self._headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return MergeRequestRef(None, None, False, f"HTTP {response.status_code}: {response.text[:300]}")
|
||||||
|
data = response.json()
|
||||||
|
return MergeRequestRef(iid=data.get("iid"), web_url=data.get("web_url"), created=True)
|
||||||
|
|
||||||
|
def comment_on_issue(self, issue_iid: str | int, body: str) -> bool:
|
||||||
|
if not self.enabled:
|
||||||
|
return False
|
||||||
|
response = httpx.post(
|
||||||
|
f"{self._api}/projects/{self.settings.gitlab_project_id}/issues/{issue_iid}/notes",
|
||||||
|
headers=self._headers(),
|
||||||
|
json={"body": body},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return response.status_code < 400
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Fabryka modeli - jedyne miejsce, które wie o dostawcy LLM.
|
||||||
|
|
||||||
|
Domyślnie self-hosted vLLM z API zgodnym z OpenAI. Ollama jako ścieżka zapasowa
|
||||||
|
(lokalny development, środowisko bez GPU). Wymiana dostawcy = zmiana zmiennej
|
||||||
|
środowiskowej, nie zmiana kodu agentów.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class ModelFactory:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self._cache: dict[tuple[str, float | None], Any] = {}
|
||||||
|
|
||||||
|
def for_profile(self, profile: str, temperature: float | None = None) -> Any:
|
||||||
|
model_id = self.settings.models.get(profile)
|
||||||
|
key = (model_id, temperature)
|
||||||
|
if key not in self._cache:
|
||||||
|
self._cache[key] = self._build(model_id, temperature)
|
||||||
|
return self._cache[key]
|
||||||
|
|
||||||
|
def _build(self, model_id: str, temperature: float | None) -> Any:
|
||||||
|
provider = self.settings.provider.lower()
|
||||||
|
kwargs: dict[str, Any] = {"id": model_id}
|
||||||
|
if temperature is not None:
|
||||||
|
kwargs["temperature"] = temperature
|
||||||
|
|
||||||
|
if provider in {"vllm", "openai_like", "openai"}:
|
||||||
|
from agno.models.openai.like import OpenAILike
|
||||||
|
|
||||||
|
return OpenAILike(
|
||||||
|
base_url=self.settings.base_url,
|
||||||
|
api_key=self.settings.api_key or "not-required",
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if provider == "ollama":
|
||||||
|
from agno.models.ollama import Ollama
|
||||||
|
|
||||||
|
host = self.settings.base_url.removesuffix("/v1").removesuffix("/v1/")
|
||||||
|
return Ollama(host=host or None, **kwargs)
|
||||||
|
|
||||||
|
raise ValueError(f"Nieznany dostawca modelu: '{provider}'. Obsługiwane: vllm, openai_like, ollama.")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Ślad audytowy przebiegu.
|
||||||
|
|
||||||
|
W środowisku regulowanym (DORA, EU AI Act) trzeba umieć odtworzyć: kto zlecił zmianę,
|
||||||
|
jaki kontekst dostał model, jakie narzędzia wywołał, co dokładnie zmienił i kto to zatwierdził.
|
||||||
|
Każde wywołanie narzędzia agenta przechodzi przez `AuditLog.record`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Wzorce redakcji - log audytowy jest artefaktem CI i bywa czytany szeroko.
|
||||||
|
_SECRET_PATTERNS = [
|
||||||
|
re.compile(r"(?i)(api[_-]?key|token|password|secret|authorization)\s*[:=]\s*['\"]?([^\s'\"]{6,})"),
|
||||||
|
re.compile(r"glpat-[A-Za-z0-9_\-]{10,}"),
|
||||||
|
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{10,}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def redact(text: str) -> str:
|
||||||
|
out = text
|
||||||
|
for pattern in _SECRET_PATTERNS:
|
||||||
|
out = pattern.sub(
|
||||||
|
lambda m: (
|
||||||
|
(m.group(0)[: m.start(2) - m.start(0)] + "***REDACTED***") if m.re.groups >= 2 else "***REDACTED***"
|
||||||
|
),
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuditLog:
|
||||||
|
run_dir: Path
|
||||||
|
entries: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
_started: float = field(default_factory=time.monotonic)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.run_dir = Path(self.run_dir)
|
||||||
|
self.run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._path = self.run_dir / "trace.jsonl"
|
||||||
|
|
||||||
|
def record(self, tool: str, args: dict[str, Any], ok: bool = True, detail: str = "") -> None:
|
||||||
|
entry = {
|
||||||
|
"t": round(time.monotonic() - self._started, 3),
|
||||||
|
"tool": tool,
|
||||||
|
"args": {k: redact(str(v))[:400] for k, v in args.items()},
|
||||||
|
"ok": ok,
|
||||||
|
"detail": redact(detail)[:600],
|
||||||
|
}
|
||||||
|
self.entries.append(entry)
|
||||||
|
with self._path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
def event(self, name: str, **payload: Any) -> None:
|
||||||
|
self.record(tool=f"event:{name}", args=payload)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tool_call_count(self) -> int:
|
||||||
|
return sum(1 for e in self.entries if not e["tool"].startswith("event:"))
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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 <agentic-codemod@pipeline.local>") -> str:
|
||||||
|
self._run("add", "-A")
|
||||||
|
if not self._run("diff", "--cached", "--name-only"):
|
||||||
|
raise GitError("Brak zmian do zacommitowania")
|
||||||
|
self._run("commit", "-q", "-m", message, f"--author={author}")
|
||||||
|
return self._run("rev-parse", "HEAD")
|
||||||
|
|
||||||
|
def push(self, remote: str = "origin", branch: str | None = None, force: bool = False) -> str:
|
||||||
|
branch = branch or self.current_branch()
|
||||||
|
args = ["push", "-q", remote, f"HEAD:refs/heads/{branch}"]
|
||||||
|
if force:
|
||||||
|
args.insert(1, "--force-with-lease")
|
||||||
|
return self._run(*args)
|
||||||
@@ -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}"
|
||||||
@@ -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]
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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}")
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Adaptery ekosystemów - wiedza deterministyczna, więc testowana punktowo."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentic_codemod.adapters import (
|
||||||
|
EcosystemNotDetected,
|
||||||
|
MavenAdapter,
|
||||||
|
NodeNpmAdapter,
|
||||||
|
PythonPipAdapter,
|
||||||
|
detect_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wykrywanie_ekosystemu_python(acme_app):
|
||||||
|
assert isinstance(detect_adapter(acme_app), PythonPipAdapter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_brak_rozpoznanego_ekosystemu_to_twardy_blad(tmp_path):
|
||||||
|
with pytest.raises(EcosystemNotDetected):
|
||||||
|
detect_adapter(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_odczyt_i_podniesienie_wersji(acme_app):
|
||||||
|
adapter = PythonPipAdapter(acme_app)
|
||||||
|
assert adapter.read_declared_version("acme-sdk") == "==1.4.2"
|
||||||
|
changed = adapter.set_declared_version("acme-sdk", "2.1.0")
|
||||||
|
assert [p.name for p in changed] == ["pyproject.toml"]
|
||||||
|
assert adapter.read_declared_version("acme-sdk") == "==2.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_nie_dotyka_prozy_zawierajacej_nazwe_pakietu(acme_app):
|
||||||
|
"""Regresja: wzorzec bez wymaganego operatora podmieniał też opis projektu."""
|
||||||
|
adapter = PythonPipAdapter(acme_app)
|
||||||
|
adapter.set_declared_version("acme-sdk", "2.1.0")
|
||||||
|
content = (acme_app / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
assert "acme-sdk 1.x - fixture" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_zachowuje_operator_porownania(tmp_path):
|
||||||
|
(tmp_path / "requirements.txt").write_text("acme-sdk >= 1.4\nhttpx==0.27.0\n", encoding="utf-8")
|
||||||
|
adapter = PythonPipAdapter(tmp_path)
|
||||||
|
adapter.set_declared_version("acme-sdk", "2.1.0")
|
||||||
|
assert (tmp_path / "requirements.txt").read_text(encoding="utf-8").startswith("acme-sdk >= 2.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_maven_podnosi_wersje_zaleznosci(tmp_path):
|
||||||
|
(tmp_path / "pom.xml").write_text(
|
||||||
|
"<project><dependencies><dependency>"
|
||||||
|
"<groupId>com.acme</groupId><artifactId>acme-sdk</artifactId><version>1.4.2</version>"
|
||||||
|
"</dependency></dependencies></project>",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
adapter = MavenAdapter(tmp_path)
|
||||||
|
assert adapter.read_declared_version("com.acme:acme-sdk") == "1.4.2"
|
||||||
|
adapter.set_declared_version("com.acme:acme-sdk", "2.1.0")
|
||||||
|
assert adapter.read_declared_version("com.acme:acme-sdk") == "2.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_npm_zachowuje_prefiks_zakresu(tmp_path):
|
||||||
|
(tmp_path / "package.json").write_text(json.dumps({"dependencies": {"acme-sdk": "^1.4.2"}}), encoding="utf-8")
|
||||||
|
adapter = NodeNpmAdapter(tmp_path)
|
||||||
|
adapter.set_declared_version("acme-sdk", "2.1.0")
|
||||||
|
assert adapter.read_declared_version("acme-sdk") == "^2.1.0"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user