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