feat: initial commit

This commit is contained in:
2026-08-29 13:17:59 +02:00
commit 142f5f5759
91 changed files with 6155 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
"""Referencyjna sieć agentowa do modyfikacji kodu.
Warstwy:
apm/ - kontekst agentów (skille, prompty, instrukcje, definicje agentów) z pakietów APM
adapters/ - wiedza o ekosystemach budowania (pip, maven, npm)
tools/ - sandboxowane narzędzia agno, jedyny sposób kontaktu agenta z repozytorium
workflow/ - topologia sieci agentowej jako agno Workflow
observability/ - ślad audytowy przebiegu
integrations/ - GitLab (MR, komentarze)
"""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import app
if __name__ == "__main__":
app()
+43
View File
@@ -0,0 +1,43 @@
"""Rejestr adapterów ekosystemów."""
from __future__ import annotations
from pathlib import Path
from .base import EcosystemAdapter
from .maven import MavenAdapter
from .node_npm import NodeNpmAdapter
from .python_pip import PythonPipAdapter
REGISTRY: tuple[type[EcosystemAdapter], ...] = (PythonPipAdapter, MavenAdapter, NodeNpmAdapter)
class EcosystemNotDetected(RuntimeError):
pass
def detect_adapter(root: Path | str) -> EcosystemAdapter:
"""Wybiera adapter na podstawie plików markerowych. Brak dopasowania = twardy błąd.
Świadomie nie ma tu fallbacku "spróbuj czegokolwiek" - pipeline modyfikujący kod
w banku musi wiedzieć, czym jest repozytorium, zanim cokolwiek zmieni.
"""
root = Path(root)
for adapter_cls in REGISTRY:
if adapter_cls.detect(root):
return adapter_cls(root)
raise EcosystemNotDetected(
f"Nie rozpoznano ekosystemu w {root}. Obsługiwane markery: "
+ ", ".join(sorted({m for a in REGISTRY for m in a.marker_files}))
)
__all__ = [
"EcosystemAdapter",
"EcosystemNotDetected",
"MavenAdapter",
"NodeNpmAdapter",
"PythonPipAdapter",
"REGISTRY",
"detect_adapter",
]
+57
View File
@@ -0,0 +1,57 @@
"""Adaptery ekosystemów budowania.
Sieć agentowa jest agnostyczna językowo. Wiedza "gdzie stoi wersja zależności i jak
uruchomić testy" jest deterministyczna i nie powinna być zgadywana przez model -
mieszka tutaj. Model dostaje wynik adaptera jako fakt.
"""
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from pathlib import Path
class EcosystemAdapter(ABC):
name: str = "base"
#: pliki, których obecność świadczy o ekosystemie (kolejność = priorytet)
marker_files: tuple[str, ...] = ()
def __init__(self, root: Path) -> None:
self.root = Path(root).resolve()
# --------------------------------------------------------------- detekcja
@classmethod
def detect(cls, root: Path) -> bool:
return any((Path(root) / marker).exists() for marker in cls.marker_files)
# ------------------------------------------------------------- zależności
@abstractmethod
def dependency_files(self) -> list[Path]:
"""Pliki deklarujące zależności, które realnie istnieją w repozytorium."""
@abstractmethod
def read_declared_version(self, package: str) -> str | None:
"""Zwraca deklarowaną wersję pakietu tak, jak jest zapisana (bez normalizacji)."""
@abstractmethod
def set_declared_version(self, package: str, version: str) -> list[Path]:
"""Ustawia wersję pakietu we wszystkich plikach zależności. Zwraca zmienione pliki."""
def module_name(self, package: str) -> str:
"""Heurystyka: nazwa pakietu dystrybucyjnego -> nazwa modułu importu."""
return package.replace("-", "_")
# ------------------------------------------------------------ weryfikacja
@abstractmethod
def verify_command(self) -> list[str]:
"""Komenda budująca i testująca repozytorium."""
def install_command(self) -> list[str] | None:
"""Opcjonalna komenda przygotowania środowiska (offline w CI)."""
return None
def _replace_in_text(text: str, pattern: re.Pattern[str], replacement) -> tuple[str, int]:
new_text, count = pattern.subn(replacement, text)
return new_text, count
+62
View File
@@ -0,0 +1,62 @@
"""Ekosystem Java/Maven - wersje trzymane w properties lub w <dependency>.
Status: adapter referencyjny (bez fixture w tym repozytorium). Pokazuje, jak dołożyć
kolejny ekosystem bez dotykania warstwy agentowej.
"""
from __future__ import annotations
import re
from pathlib import Path
from .base import EcosystemAdapter
class MavenAdapter(EcosystemAdapter):
name = "java-maven"
marker_files = ("pom.xml",)
def dependency_files(self) -> list[Path]:
return [p for p in [self.root / "pom.xml", *sorted(self.root.glob("*/pom.xml"))] if p.exists()]
@staticmethod
def _coords(package: str) -> tuple[str, str]:
if ":" not in package:
raise ValueError("Dla Mavena podaj koordynaty w formacie groupId:artifactId")
group, artifact = package.split(":", 1)
return group, artifact
def _dependency_pattern(self, package: str) -> re.Pattern[str]:
group, artifact = self._coords(package)
return re.compile(
rf"(<groupId>\s*{re.escape(group)}\s*</groupId>\s*"
rf"<artifactId>\s*{re.escape(artifact)}\s*</artifactId>\s*"
rf"<version>)(?P<version>[^<]+)(</version>)",
re.DOTALL,
)
def read_declared_version(self, package: str) -> str | None:
pattern = self._dependency_pattern(package)
for path in self.dependency_files():
match = pattern.search(path.read_text(encoding="utf-8"))
if match:
return match.group("version").strip()
return None
def set_declared_version(self, package: str, version: str) -> list[Path]:
pattern = self._dependency_pattern(package)
changed: list[Path] = []
for path in self.dependency_files():
original = path.read_text(encoding="utf-8")
updated = pattern.sub(rf"\g<1>{version}\g<3>", original)
if updated != original:
path.write_text(updated, encoding="utf-8")
changed.append(path)
return changed
def module_name(self, package: str) -> str:
group, _ = self._coords(package)
return group
def verify_command(self) -> list[str]:
return ["mvn", "-B", "-o", "verify"]
+55
View File
@@ -0,0 +1,55 @@
"""Ekosystem Node/npm - wersje w package.json.
Status: adapter referencyjny (bez fixture w tym repozytorium).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from .base import EcosystemAdapter
_SECTIONS = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies")
class NodeNpmAdapter(EcosystemAdapter):
name = "node-npm"
marker_files = ("package.json",)
def dependency_files(self) -> list[Path]:
return [p for p in [self.root / "package.json"] if p.exists()]
def read_declared_version(self, package: str) -> str | None:
for path in self.dependency_files():
data = json.loads(path.read_text(encoding="utf-8"))
for section in _SECTIONS:
value = (data.get(section) or {}).get(package)
if value:
return str(value)
return None
def set_declared_version(self, package: str, version: str) -> list[Path]:
changed: list[Path] = []
for path in self.dependency_files():
original = path.read_text(encoding="utf-8")
data = json.loads(original)
updated = original
for section in _SECTIONS:
current = (data.get(section) or {}).get(package)
if not current:
continue
prefix = re.match(r"^[\^~]?", str(current)).group(0)
pattern = re.compile(rf'("{re.escape(package)}"\s*:\s*")[^"]+(")')
updated = pattern.sub(rf"\g<1>{prefix}{version}\g<2>", updated)
if updated != original:
path.write_text(updated, encoding="utf-8")
changed.append(path)
return changed
def module_name(self, package: str) -> str:
return package
def verify_command(self) -> list[str]:
return ["npm", "test", "--silent"]
@@ -0,0 +1,86 @@
"""Ekosystem Python: pyproject.toml, requirements*.txt, constraints.txt."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from .base import EcosystemAdapter
def _requirement_pattern(package: str) -> re.Pattern[str]:
"""Dopasowuje deklarację zależności: acme-sdk==1.4.2, acme_sdk >= 1.4, "acme-sdk~=1.4.2".
Operator jest WYMAGANY. Bez tego wzorzec łapie też prozę ("acme-sdk 1.x" w opisie
projektu) i pipeline modyfikuje tekst, którego nikt go nie prosił o zmianę -
dokładnie ten rodzaj cichego rozszerzenia zakresu, którego zakazują guardraile.
"""
name = re.escape(package).replace(r"\-", "[-_]")
return re.compile(
rf"(?P<name>\b{name}\b)(?P<space>\s*)(?P<op>==|>=|<=|~=|!=|>|<)(?P<space2>\s*)(?P<version>[0-9][^\s,\"'\]]*)",
re.IGNORECASE,
)
class PythonPipAdapter(EcosystemAdapter):
name = "python-pip"
marker_files = ("pyproject.toml", "requirements.txt", "setup.cfg", "setup.py")
_candidates = ("pyproject.toml", "requirements.txt", "requirements-dev.txt", "constraints.txt", "setup.cfg")
def dependency_files(self) -> list[Path]:
files = [self.root / name for name in self._candidates]
files.extend(sorted(self.root.glob("requirements/*.txt")))
return [f for f in files if f.exists()]
def read_declared_version(self, package: str) -> str | None:
pattern = _requirement_pattern(package)
for path in self.dependency_files():
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
match = pattern.search(line)
if match and match.group("version"):
return f"{match.group('op') or ''}{match.group('version')}"
return None
def set_declared_version(self, package: str, version: str) -> list[Path]:
pattern = _requirement_pattern(package)
changed: list[Path] = []
for path in self.dependency_files():
original = path.read_text(encoding="utf-8")
lines = original.splitlines(keepends=True)
updated: list[str] = []
touched = False
for line in lines:
if line.strip().startswith("#") or not pattern.search(line):
updated.append(line)
continue
def _sub(match: re.Match[str]) -> str:
# zachowujemy oryginalny operator i odstępy - diff ma pokazywać
# wyłącznie zmianę wersji, nie przeformatowanie linii
return (
f"{match.group('name')}{match.group('space')}{match.group('op')}"
f"{match.group('space2')}{version}"
)
new_line = pattern.sub(_sub, line)
touched = touched or new_line != line
updated.append(new_line)
if touched:
path.write_text("".join(updated), encoding="utf-8")
changed.append(path)
return changed
def verify_command(self) -> list[str]:
"""Weryfikacja tym samym interpreterem, który uruchamia pipeline.
Nie `python3` z PATH: przy uruchomieniu z venva (a tak działa Taskfile i obraz CI)
systemowy `python3` na macOS nie ma pytesta i weryfikacja byłaby czerwona
z powodu środowiska, a nie z powodu kodu. Repozytorium z własnym środowiskiem
budowania nadpisuje komendę przez `--verify-command`.
"""
return [sys.executable or "python3", "-m", "pytest", "-q"]
View File
+88
View File
@@ -0,0 +1,88 @@
"""Kompilacja prymitywów APM do obiektów agno.
To jest sedno rozwiązania: definicja agenta (rola, model, narzędzia, skille, schemat wyjścia)
jest wersjonowanym artefaktem APM, a nie kodem. Zmiana zachowania sieci agentowej
nie wymaga zmiany Pythona - wymaga podbicia wersji pakietu kontekstowego.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from agno.agent import Agent
from agno.skills import LocalSkills, Skills
from ..config import Settings
from ..llm import ModelFactory
from .loader import ApmContext
from .primitives import AgentPrimitive
class ToolResolutionError(RuntimeError):
pass
def _resolve_tools(names: list[str], registry: dict[str, Callable[..., Any]]) -> list[Callable[..., Any]]:
unknown = [n for n in names if n not in registry]
if unknown:
raise ToolResolutionError(
f"Definicja agenta odwołuje się do nieistniejących narzędzi: {', '.join(unknown)}. "
f"Dostępne: {', '.join(sorted(registry))}"
)
return [registry[n] for n in names]
def build_skills(ctx: ApmContext, names: list[str]) -> Skills | None:
"""Buduje natywny obiekt `Skills` agno z katalogów skilli dostarczonych przez APM.
agno waliduje katalogi względem specyfikacji Agent Skills - niepoprawny skill
wysadza przebieg na starcie, a nie w połowie modyfikacji kodu.
"""
if not names:
return None
paths = ctx.skill_paths(names)
return Skills(loaders=[LocalSkills(str(p)) for p in paths])
def build_agent(
definition: AgentPrimitive,
ctx: ApmContext,
settings: Settings,
model_factory: ModelFactory,
tool_registry: dict[str, Callable[..., Any]],
schema_registry: dict[str, type] | None = None,
) -> Agent:
schema_registry = schema_registry or {}
instructions: list[str] = []
instructions.extend(ctx.context_bodies(definition.context_names))
instructions.append(definition.body)
instructions.extend(ctx.instruction_bodies(definition.instruction_names))
output_schema = None
if definition.output_schema_name:
if definition.output_schema_name not in schema_registry:
raise KeyError(
f"Agent '{definition.name}' deklaruje output_schema='{definition.output_schema_name}', "
f"którego nie ma w rejestrze schematów: {sorted(schema_registry)}"
)
output_schema = schema_registry[definition.output_schema_name]
return Agent(
name=definition.name,
description=definition.description or None,
model=model_factory.for_profile(definition.model_profile, definition.temperature),
instructions=instructions,
tools=_resolve_tools(definition.tool_names, tool_registry) or None,
skills=build_skills(ctx, definition.skill_names),
output_schema=output_schema,
tool_call_limit=definition.tool_call_limit or settings.tool_call_limit,
markdown=False,
telemetry=False,
add_datetime_to_context=True,
# Nazwa roli w kontekście systemowym: pomaga modelowi trzymać się swojego zadania,
# a atrapie LLM (llm/mock_server.py) rozpoznać, który agent pyta.
add_name_to_context=True,
retries=2,
)
+137
View File
@@ -0,0 +1,137 @@
"""Odkrywanie kontekstu agentowego dostarczonego przez APM.
APM instaluje zależności do `apm_modules/<pakiet>/`, a własne prymitywy repozytorium
leżą w `.apm/`. Loader traktuje oba źródła jednolicie: prymitywy lokalne mają
pierwszeństwo przed zainstalowanymi (możliwość nadpisania skilla organizacji lokalnie).
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from .primitives import AgentPrimitive, InstructionPrimitive, PromptPrimitive, SkillRef, parse_frontmatter
@dataclass
class ApmContext:
root: Path
instructions: dict[str, InstructionPrimitive] = field(default_factory=dict)
prompts: dict[str, PromptPrimitive] = field(default_factory=dict)
agents: dict[str, AgentPrimitive] = field(default_factory=dict)
skills: dict[str, SkillRef] = field(default_factory=dict)
context_fragments: dict[str, str] = field(default_factory=dict)
packages: list[str] = field(default_factory=list)
lockfile_hash: str | None = None
# -- dostęp ------------------------------------------------------------
def prompt(self, name: str) -> PromptPrimitive:
if name not in self.prompts:
raise KeyError(f"Brak promptu '{name}' w kontekście APM. Dostępne: {sorted(self.prompts)}")
return self.prompts[name]
def agent(self, name: str) -> AgentPrimitive:
if name not in self.agents:
raise KeyError(f"Brak definicji agenta '{name}' w kontekście APM. Dostępne: {sorted(self.agents)}")
return self.agents[name]
def skill_paths(self, names: list[str]) -> list[Path]:
missing = [n for n in names if n not in self.skills]
if missing:
raise KeyError(f"Brak skilli w kontekście APM: {', '.join(missing)}. Uruchom `apm install`.")
return [self.skills[n].path for n in names]
def instruction_bodies(self, names: list[str]) -> list[str]:
missing = [n for n in names if n not in self.instructions]
if missing:
raise KeyError(f"Brak instrukcji w kontekście APM: {', '.join(missing)}")
return [self.instructions[n].body for n in names]
def context_bodies(self, names: list[str]) -> list[str]:
return [self.context_fragments[n] for n in names if n in self.context_fragments]
def summary(self) -> dict[str, object]:
"""Wpis do manifestu przebiegu - z czego dokładnie zbudowano kontekst."""
return {
"root": str(self.root),
"packages": self.packages,
"lockfile_hash": self.lockfile_hash,
"skills": sorted(self.skills),
"instructions": sorted(self.instructions),
"prompts": sorted(self.prompts),
"agents": sorted(self.agents),
}
def _load_dir(base: Path, package: str, ctx: ApmContext) -> None:
apm_dir = base / ".apm"
if not apm_dir.is_dir():
return
for path in sorted((apm_dir / "instructions").glob("*.md")):
prim = InstructionPrimitive.from_file(path, package)
ctx.instructions.setdefault(prim.name, prim)
for path in sorted((apm_dir / "prompts").glob("*.md")):
prim = PromptPrimitive.from_file(path, package)
ctx.prompts.setdefault(prim.name, prim)
for path in sorted((apm_dir / "agents").glob("*.md")):
prim = AgentPrimitive.from_file(path, package)
ctx.agents.setdefault(prim.name, prim)
for path in sorted((apm_dir / "context").glob("*.md")):
_, body = parse_frontmatter(path.read_text(encoding="utf-8"))
ctx.context_fragments.setdefault(path.stem, body)
skills_dir = apm_dir / "skills"
if skills_dir.is_dir():
for folder in sorted(p for p in skills_dir.iterdir() if p.is_dir()):
skill_md = folder / "SKILL.md"
if not skill_md.exists():
continue
meta, _ = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
name = str(meta.get("name") or folder.name)
ctx.skills.setdefault(
name, SkillRef(name=name, path=folder, description=str(meta.get("description", "")), package=package)
)
def load_apm_context(root: Path | str = ".") -> ApmContext:
"""Buduje kontekst z `.apm/` repozytorium oraz z zainstalowanych `apm_modules/`.
Kolejność ma znaczenie: najpierw lokalne (wygrywają przy konflikcie nazw),
potem zainstalowane pakiety.
"""
root = Path(root).resolve()
ctx = ApmContext(root=root)
_load_dir(root, package="local", ctx=ctx)
modules = root / "apm_modules"
if modules.is_dir():
for pkg_dir in sorted(p for p in modules.rglob("*") if (p / ".apm").is_dir()):
_load_dir(pkg_dir, package=str(pkg_dir.relative_to(modules)), ctx=ctx)
ctx.packages.append(str(pkg_dir.relative_to(modules)))
lock = root / "apm.lock.yaml"
if lock.exists():
raw = lock.read_bytes()
ctx.lockfile_hash = hashlib.sha256(raw).hexdigest()[:16]
try:
data = yaml.safe_load(raw) or {}
deps = data.get("dependencies") or {}
if isinstance(deps, dict):
ctx.packages = sorted({*ctx.packages, *deps.keys()})
except yaml.YAMLError:
pass
if not ctx.agents:
raise RuntimeError(
f"Nie znaleziono definicji agentów w {root}/.apm/agents. "
"Sieć agentowa jest konfigurowana przez APM - bez kontekstu nie ma czego uruchomić."
)
return ctx
+141
View File
@@ -0,0 +1,141 @@
"""Parsowanie prymitywów APM (pliki Markdown z frontmatterem YAML)."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""Rozdziela dokument na frontmatter i treść. Brak frontmattera nie jest błędem."""
match = _FRONTMATTER.match(text)
if not match:
return {}, text.strip()
try:
meta = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError as exc: # pragma: no cover - defensywnie
raise ValueError(f"Niepoprawny frontmatter YAML: {exc}") from exc
if not isinstance(meta, dict):
raise ValueError("Frontmatter musi być mapą klucz-wartość")
return meta, match.group(2).strip()
@dataclass
class Primitive:
name: str
description: str
body: str
path: Path
meta: dict[str, Any] = field(default_factory=dict)
package: str = "local"
@classmethod
def from_file(cls, path: Path, package: str = "local") -> Primitive:
meta, body = parse_frontmatter(path.read_text(encoding="utf-8"))
name = meta.get("name") or path.stem.split(".")[0]
return cls(
name=str(name),
description=str(meta.get("description", "")),
body=body,
path=path,
meta=meta,
package=package,
)
@dataclass
class InstructionPrimitive(Primitive):
"""Reguła zawsze aktywna dla plików pasujących do `applyTo`."""
@property
def apply_to(self) -> str:
return str(self.meta.get("applyTo", "**/*"))
@dataclass
class PromptPrimitive(Primitive):
"""Szablon zadania. Placeholdery w formacie {{nazwa}}."""
@property
def inputs(self) -> list[dict[str, Any]]:
raw = self.meta.get("inputs") or []
return [i for i in raw if isinstance(i, dict)]
def required_inputs(self) -> list[str]:
return [str(i["name"]) for i in self.inputs if i.get("required") and "name" in i]
def render(self, values: dict[str, Any], strict: bool = True) -> str:
"""Podstawia wartości pod placeholdery.
strict=True wymusza obecność wszystkich pól oznaczonych jako `required` -
brak wsadu jest błędem konfiguracji pipeline'u, nie problemem do zgadnięcia przez model.
"""
if strict:
missing = [n for n in self.required_inputs() if not values.get(n)]
if missing:
raise ValueError(f"Prompt '{self.name}': brak wymaganych wejść: {', '.join(missing)}")
def _sub(match: re.Match[str]) -> str:
key = match.group(1).strip()
value = values.get(key)
return "" if value is None else str(value)
rendered = re.sub(r"\{\{([^}]+)\}\}", _sub, self.body)
# sprzątanie po pustych sekcjach opcjonalnych
return re.sub(r"\n{3,}", "\n\n", rendered).strip()
@dataclass
class AgentPrimitive(Primitive):
"""Deklaratywna definicja agenta - topologia sieci mieszka w APM, nie w kodzie."""
@property
def model_profile(self) -> str:
return str(self.meta.get("model_profile", "planner"))
@property
def temperature(self) -> float | None:
value = self.meta.get("temperature")
return None if value is None else float(value)
@property
def tool_names(self) -> list[str]:
return [str(t) for t in (self.meta.get("tools") or [])]
@property
def skill_names(self) -> list[str]:
return [str(s) for s in (self.meta.get("skills") or [])]
@property
def instruction_names(self) -> list[str]:
return [str(i) for i in (self.meta.get("instructions") or [])]
@property
def context_names(self) -> list[str]:
return [str(c) for c in (self.meta.get("context") or [])]
@property
def output_schema_name(self) -> str | None:
value = self.meta.get("output_schema")
return None if value is None else str(value)
@property
def tool_call_limit(self) -> int | None:
value = self.meta.get("tool_call_limit")
return None if value is None else int(value)
@dataclass
class SkillRef:
"""Skill jako katalog zgodny ze specyfikacją Agent Skills - agno ładuje go natywnie."""
name: str
path: Path
description: str = ""
package: str = "local"
+127
View File
@@ -0,0 +1,127 @@
"""Interfejs wiersza poleceń - punkt wejścia dla joba GitLab CI i dla developera."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from .apm.loader import load_apm_context
from .config import Settings
from .schemas import ChangeRequest, TaskType
from .workflow.runner import run_pipeline
app = typer.Typer(add_completion=False, help="Sieć agentowa modyfikująca kod (agno + APM + GitLab CI)")
console = Console()
EXIT_CODES = {"success": 0, "no_changes": 0, "blocked": 3, "failed": 1, "running": 1}
@app.command()
def run(
repo: Path = typer.Option(..., "--repo", help="Ścieżka do repozytorium docelowego"),
package: str | None = typer.Option(None, "--package", help="Pakiet do podniesienia (np. acme-sdk)"),
to_version: str | None = typer.Option(None, "--to-version", help="Wersja docelowa"),
from_version: str | None = typer.Option(None, "--from-version", help="Wersja aktualna (opcjonalnie)"),
module: str | None = typer.Option(None, "--module", help="Nazwa modułu importu, jeśli inna niż pakiet"),
prompt: str = typer.Option("sdk-upgrade", "--prompt", help="Nazwa promptu APM stanowiącego wsad zadania"),
constraints: str = typer.Option("", "--constraints", help="Dodatkowe ograniczenia z issue / ADR"),
issue: str | None = typer.Option(None, "--issue", help="Numer issue do skomentowania po publikacji"),
offline: bool = typer.Option(False, "--offline", help="Tryb deterministyczny: reguły codemod zamiast LLM"),
plan_only: bool = typer.Option(False, "--plan-only", help="Zatrzymaj się na planie, nie zmieniaj kodu"),
in_place: bool = typer.Option(False, "--in-place", help="Pracuj na wskazanym katalogu zamiast na kopii"),
publish: bool = typer.Option(False, "--publish", help="Utwórz gałąź, wypchnij i otwórz merge request"),
target_branch: str = typer.Option("main", "--target-branch"),
verify: str | None = typer.Option(None, "--verify-command", help="Nadpisanie komendy weryfikacji"),
run_dir: Path | None = typer.Option(None, "--run-dir", help="Katalog artefaktów przebiegu"),
apm_root: Path | None = typer.Option(None, "--apm-root", help="Katalog z .apm/ i apm_modules/"),
) -> None:
"""Uruchamia pełny przebieg sieci agentowej."""
settings = Settings.from_env()
if run_dir:
settings.run_dir = run_dir
if apm_root:
settings.apm_root = apm_root.resolve()
request = ChangeRequest(
task_type=TaskType.SDK_UPGRADE if package else TaskType.CUSTOM,
prompt_name=prompt,
repo_path=str(repo),
package=package,
module_name=module,
from_version=from_version,
to_version=to_version,
constraints=constraints,
issue_ref=issue,
)
manifest = run_pipeline(
request=request,
settings=settings,
mode="offline" if offline else "llm",
in_place=in_place,
plan_only=plan_only,
publish=publish,
target_branch=target_branch,
verify_command=verify.split() if verify else None,
)
_print_summary(manifest, settings)
raise typer.Exit(EXIT_CODES.get(manifest.status, 1))
@app.command()
def context(
apm_root: Path = typer.Option(Path("."), "--apm-root", help="Katalog z .apm/ i apm_modules/"),
as_json: bool = typer.Option(False, "--json"),
) -> None:
"""Pokazuje, jaki kontekst agentowy dostarczyło APM. Używane jako bramka w CI."""
ctx = load_apm_context(apm_root)
if as_json:
console.print_json(json.dumps(ctx.summary(), ensure_ascii=False))
return
table = Table(title=f"Kontekst APM ({ctx.root})", show_lines=False)
table.add_column("Prymityw")
table.add_column("Nazwa")
table.add_column("Pakiet")
for name, prim in sorted(ctx.agents.items()):
table.add_row("agent", name, prim.package)
for name, ref in sorted(ctx.skills.items()):
table.add_row("skill", name, ref.package)
for name, prim in sorted(ctx.prompts.items()):
table.add_row("prompt", name, prim.package)
for name, prim in sorted(ctx.instructions.items()):
table.add_row("instruction", name, prim.package)
console.print(table)
console.print(f"lock: [bold]{ctx.lockfile_hash or 'brak apm.lock.yaml'}[/bold]")
def _print_summary(manifest, settings: Settings) -> None:
color = {"success": "green", "no_changes": "yellow", "blocked": "yellow", "failed": "red"}.get(
manifest.status, "white"
)
console.print(f"\n[bold {color}]status: {manifest.status}[/bold {color}] run_id={manifest.run_id}")
console.print(
f"tryb: {manifest.mode} | iteracje: {manifest.iterations} | wywołania narzędzi: {len(manifest.tool_calls)}"
)
if manifest.changed_files:
console.print("zmienione pliki:")
for path in manifest.changed_files:
console.print(f" - {path}")
if manifest.verifications:
last = manifest.verifications[-1]
console.print(f"weryfikacja: {last.command} -> rc={last.returncode} ({last.duration_s}s)")
if manifest.review:
console.print(f"recenzja: {manifest.review.verdict} - {manifest.review.summary}")
for error in manifest.errors:
console.print(f"[red]![/red] {error}")
console.print(f"artefakty: {settings.run_dir}")
if __name__ == "__main__": # pragma: no cover
sys.exit(app())
+86
View File
@@ -0,0 +1,86 @@
"""Konfiguracja przebiegu - wyłącznie ze zmiennych środowiskowych.
W GitLab CI zmienne pochodzą z ustawień projektu/grupy (masked + protected).
Kod nigdy nie czyta sekretów z plików w repozytorium.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
# Ścieżki, których agent nie może dotknąć niezależnie od treści planu.
# To ostatnia linia obrony - nie polegamy na tym, że model przeczyta guardraile.
DEFAULT_DENY_GLOBS: tuple[str, ...] = (
".git/**",
".gitlab-ci.yml",
".gitlab/**",
".github/workflows/**",
"Dockerfile*",
"**/Chart.yaml",
"**/*.pem",
"**/*.key",
"**/*secret*",
".env",
".env.*",
"apm-policy.yml",
"apm.lock.yaml",
)
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
@dataclass
class ModelProfiles:
"""Model per rola - planista może być większy niż redaktor opisu MR."""
planner: str = field(default_factory=lambda: _env("CODEMOD_MODEL_PLANNER", "Qwen/Qwen3-32B"))
coder: str = field(default_factory=lambda: _env("CODEMOD_MODEL_CODER", "Qwen/Qwen3-Coder-30B"))
reviewer: str = field(default_factory=lambda: _env("CODEMOD_MODEL_REVIEWER", "Qwen/Qwen3-32B"))
scribe: str = field(default_factory=lambda: _env("CODEMOD_MODEL_SCRIBE", "Qwen/Qwen3-8B"))
def get(self, profile: str) -> str:
return getattr(self, profile, self.planner)
def as_dict(self) -> dict[str, str]:
return {"planner": self.planner, "coder": self.coder, "reviewer": self.reviewer, "scribe": self.scribe}
@dataclass
class Settings:
# --- backend LLM ---
provider: str = field(default_factory=lambda: _env("CODEMOD_MODEL_PROVIDER", "vllm"))
base_url: str = field(default_factory=lambda: _env("CODEMOD_LLM_BASE_URL", "http://localhost:8000/v1"))
api_key: str = field(default_factory=lambda: _env("CODEMOD_LLM_API_KEY", "not-required"))
models: ModelProfiles = field(default_factory=ModelProfiles)
# --- budżety i guardraile ---
max_iterations: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_ITERATIONS", 4))
max_files_changed: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_FILES_CHANGED", 40))
max_file_bytes: int = field(default_factory=lambda: _int_env("CODEMOD_MAX_FILE_BYTES", 400_000))
tool_call_limit: int = field(default_factory=lambda: _int_env("CODEMOD_TOOL_CALL_LIMIT", 60))
verify_timeout_s: int = field(default_factory=lambda: _int_env("CODEMOD_VERIFY_TIMEOUT_S", 900))
deny_globs: tuple[str, ...] = DEFAULT_DENY_GLOBS
# --- GitLab ---
gitlab_url: str = field(default_factory=lambda: _env("CI_SERVER_URL", ""))
gitlab_project_id: str = field(default_factory=lambda: _env("CI_PROJECT_ID", ""))
gitlab_token: str = field(default_factory=lambda: _env("CODEMOD_GITLAB_TOKEN", ""))
# --- ścieżki ---
apm_root: Path = field(default_factory=lambda: Path(_env("CODEMOD_APM_ROOT", ".")).resolve())
run_dir: Path = field(default_factory=lambda: Path(_env("CODEMOD_RUN_DIR", ".runs/current")))
@classmethod
def from_env(cls) -> Settings:
return cls()
@@ -0,0 +1,77 @@
"""Minimalny klient GitLaba - tylko to, czego pipeline naprawdę potrzebuje.
Świadomie bez `python-gitlab`: mniejsza powierzchnia zależności w obrazie runnera
i pełna kontrola nad tym, jakie wywołania API wykonuje job modyfikujący kod.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..config import Settings
@dataclass
class MergeRequestRef:
iid: int | None
web_url: str | None
created: bool
detail: str = ""
class GitLabClient:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.enabled = bool(settings.gitlab_url and settings.gitlab_project_id and settings.gitlab_token)
@property
def _api(self) -> str:
return f"{self.settings.gitlab_url.rstrip('/')}/api/v4"
def _headers(self) -> dict[str, str]:
return {"PRIVATE-TOKEN": self.settings.gitlab_token, "Content-Type": "application/json"}
def create_merge_request(
self,
source_branch: str,
target_branch: str,
title: str,
description: str,
labels: list[str] | None = None,
remove_source_branch: bool = True,
) -> MergeRequestRef:
if not self.enabled:
return MergeRequestRef(None, None, False, "Brak konfiguracji GitLaba - pominięto tworzenie MR.")
payload: dict[str, Any] = {
"source_branch": source_branch,
"target_branch": target_branch,
"title": title,
"description": description,
"labels": ",".join(labels or ["agentic-codemod"]),
"remove_source_branch": remove_source_branch,
"squash": True,
}
response = httpx.post(
f"{self._api}/projects/{self.settings.gitlab_project_id}/merge_requests",
headers=self._headers(),
json=payload,
timeout=30,
)
if response.status_code >= 400:
return MergeRequestRef(None, None, False, f"HTTP {response.status_code}: {response.text[:300]}")
data = response.json()
return MergeRequestRef(iid=data.get("iid"), web_url=data.get("web_url"), created=True)
def comment_on_issue(self, issue_iid: str | int, body: str) -> bool:
if not self.enabled:
return False
response = httpx.post(
f"{self._api}/projects/{self.settings.gitlab_project_id}/issues/{issue_iid}/notes",
headers=self._headers(),
json={"body": body},
timeout=30,
)
return response.status_code < 400
+47
View File
@@ -0,0 +1,47 @@
"""Fabryka modeli - jedyne miejsce, które wie o dostawcy LLM.
Domyślnie self-hosted vLLM z API zgodnym z OpenAI. Ollama jako ścieżka zapasowa
(lokalny development, środowisko bez GPU). Wymiana dostawcy = zmiana zmiennej
środowiskowej, nie zmiana kodu agentów.
"""
from __future__ import annotations
from typing import Any
from .config import Settings
class ModelFactory:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._cache: dict[tuple[str, float | None], Any] = {}
def for_profile(self, profile: str, temperature: float | None = None) -> Any:
model_id = self.settings.models.get(profile)
key = (model_id, temperature)
if key not in self._cache:
self._cache[key] = self._build(model_id, temperature)
return self._cache[key]
def _build(self, model_id: str, temperature: float | None) -> Any:
provider = self.settings.provider.lower()
kwargs: dict[str, Any] = {"id": model_id}
if temperature is not None:
kwargs["temperature"] = temperature
if provider in {"vllm", "openai_like", "openai"}:
from agno.models.openai.like import OpenAILike
return OpenAILike(
base_url=self.settings.base_url,
api_key=self.settings.api_key or "not-required",
**kwargs,
)
if provider == "ollama":
from agno.models.ollama import Ollama
host = self.settings.base_url.removesuffix("/v1").removesuffix("/v1/")
return Ollama(host=host or None, **kwargs)
raise ValueError(f"Nieznany dostawca modelu: '{provider}'. Obsługiwane: vllm, openai_like, ollama.")
@@ -0,0 +1,65 @@
"""Ślad audytowy przebiegu.
W środowisku regulowanym (DORA, EU AI Act) trzeba umieć odtworzyć: kto zlecił zmianę,
jaki kontekst dostał model, jakie narzędzia wywołał, co dokładnie zmienił i kto to zatwierdził.
Każde wywołanie narzędzia agenta przechodzi przez `AuditLog.record`.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Wzorce redakcji - log audytowy jest artefaktem CI i bywa czytany szeroko.
_SECRET_PATTERNS = [
re.compile(r"(?i)(api[_-]?key|token|password|secret|authorization)\s*[:=]\s*['\"]?([^\s'\"]{6,})"),
re.compile(r"glpat-[A-Za-z0-9_\-]{10,}"),
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{10,}"),
]
def redact(text: str) -> str:
out = text
for pattern in _SECRET_PATTERNS:
out = pattern.sub(
lambda m: (
(m.group(0)[: m.start(2) - m.start(0)] + "***REDACTED***") if m.re.groups >= 2 else "***REDACTED***"
),
out,
)
return out
@dataclass
class AuditLog:
run_dir: Path
entries: list[dict[str, Any]] = field(default_factory=list)
_started: float = field(default_factory=time.monotonic)
def __post_init__(self) -> None:
self.run_dir = Path(self.run_dir)
self.run_dir.mkdir(parents=True, exist_ok=True)
self._path = self.run_dir / "trace.jsonl"
def record(self, tool: str, args: dict[str, Any], ok: bool = True, detail: str = "") -> None:
entry = {
"t": round(time.monotonic() - self._started, 3),
"tool": tool,
"args": {k: redact(str(v))[:400] for k, v in args.items()},
"ok": ok,
"detail": redact(detail)[:600],
}
self.entries.append(entry)
with self._path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
def event(self, name: str, **payload: Any) -> None:
self.record(tool=f"event:{name}", args=payload)
@property
def tool_call_count(self) -> int:
return sum(1 for e in self.entries if not e["tool"].startswith("event:"))
+125
View File
@@ -0,0 +1,125 @@
"""Kontrakty danych między krokami pipeline'u.
Każdy agent zwraca strukturę z tego modułu (`output_schema` w definicji `.agent.md`).
Dzięki temu granice między agentami 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)
+35
View File
@@ -0,0 +1,35 @@
"""Rejestr narzędzi udostępnianych agentom.
Definicja agenta w `.apm/agents/*.agent.md` wymienia narzędzia po nazwie. Rejestr
tłumaczy te nazwy na konkretne, sandboxowane funkcje. Narzędzie nieobecne w rejestrze
= twardy błąd konfiguracji, a nie ciche pominięcie.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from .vcs import GitRepo
from .verification import VerificationRunner
from .workspace import WorkspaceError, WorkspaceTools
def build_tool_registry(
workspace: WorkspaceTools,
verification: VerificationRunner | None = None,
) -> dict[str, Callable[..., Any]]:
registry: dict[str, Callable[..., Any]] = {
"list_files": workspace.list_files,
"read_file": workspace.read_file,
"search_repo": workspace.search_repo,
"replace_in_file": workspace.replace_in_file,
"write_file": workspace.write_file,
"get_diff": workspace.get_diff,
}
if verification is not None:
registry["run_verification"] = verification.run_verification
return registry
__all__ = ["GitRepo", "VerificationRunner", "WorkspaceError", "WorkspaceTools", "build_tool_registry"]
+71
View File
@@ -0,0 +1,71 @@
"""Operacje git - wykonywane deterministycznie przez pipeline, nie przez model.
Agent może co najwyżej obejrzeć diff (`get_diff` w WorkspaceTools). Tworzenie gałęzi,
commit i push 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)
+103
View File
@@ -0,0 +1,103 @@
"""Weryfikacja repozytorium - jedyna dopuszczalna forma "uruchamiania czegokolwiek" przez agenta.
Agent nie dostaje powłoki. Dostaje jedną komendę, ustaloną deterministycznie przez adapter
ekosystemu (lub przez profil repozytorium), z twardym limitem czasu.
"""
from __future__ import annotations
import subprocess
import time
from pathlib import Path
from ..adapters.base import EcosystemAdapter
from ..config import Settings
from ..observability.audit import AuditLog
from ..schemas import VerificationResult
def _tail(text: str, limit: int = 4000) -> str:
text = text.strip()
return text if len(text) <= limit else "...\n" + text[-limit:]
class VerificationRunner:
def __init__(
self,
root: Path | str,
adapter: EcosystemAdapter,
settings: Settings,
audit: AuditLog,
command: list[str] | None = None,
) -> None:
self.root = Path(root).resolve()
self.adapter = adapter
self.settings = settings
self.audit = audit
self.command = command or adapter.verify_command()
self.results: list[VerificationResult] = []
def run(self) -> VerificationResult:
started = time.monotonic()
try:
proc = subprocess.run(
self.command,
cwd=self.root,
capture_output=True,
text=True,
timeout=self.settings.verify_timeout_s,
env=self._env(),
)
result = VerificationResult(
command=" ".join(self.command),
returncode=proc.returncode,
passed=proc.returncode == 0,
duration_s=round(time.monotonic() - started, 2),
output_tail=_tail(proc.stdout + "\n" + proc.stderr),
)
except subprocess.TimeoutExpired:
result = VerificationResult(
command=" ".join(self.command),
returncode=-1,
passed=False,
duration_s=round(time.monotonic() - started, 2),
output_tail=f"Przekroczono limit czasu {self.settings.verify_timeout_s}s",
timed_out=True,
)
except FileNotFoundError as exc:
result = VerificationResult(
command=" ".join(self.command),
returncode=-2,
passed=False,
output_tail=f"Nie znaleziono narzędzia weryfikacji: {exc}",
)
self.results.append(result)
self.audit.record(
"run_verification",
{"command": result.command},
ok=result.passed,
detail=f"rc={result.returncode} czas={result.duration_s}s",
)
return result
def _env(self) -> dict[str, str]:
import os
env = dict(os.environ)
# Środowisko weryfikacji nie ma dostępu do sekretów pipeline'u.
for key in list(env):
if any(marker in key.upper() for marker in ("TOKEN", "SECRET", "PASSWORD", "API_KEY")):
env.pop(key, None)
env.setdefault("PYTHONDONTWRITEBYTECODE", "1")
return env
# ----------------------------------------------------------------- tool
def run_verification(self) -> str:
"""Uruchamia build i testy repozytorium. Wywołuj po każdej zmianie pliku.
Zwraca wynik wraz z końcówką logu. Czerwony wynik napraw natychmiast,
zanim przejdziesz do kolejnego pliku.
"""
result = self.run()
status = "ZIELONO" if result.passed else "CZERWONO"
return f"[{status}] {result.command} (rc={result.returncode}, {result.duration_s}s)\n\n{result.output_tail}"
+206
View File
@@ -0,0 +1,206 @@
"""Sandboxowane narzędzia plikowe - jedyny kanał kontaktu agenta z repozytorium.
Model nigdy nie dostaje powłoki. Każda operacja jest:
- ograniczona do katalogu repozytorium (brak wyjścia przez `..` i dowiązania),
- sprawdzana względem listy zakazanych ścieżek (pipeline, sekrety, manifesty),
- limitowana rozmiarem i liczbą zmienionych plików,
- zapisywana w śladzie audytowym.
Guardraile z instrukcji APM 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]
+7
View File
@@ -0,0 +1,7 @@
"""Warstwa przepływu: kontekst, kroki, topologia agno, uruchamianie."""
from .context import PipelineContext, build_context
from .network import build_workflow
from .runner import run_pipeline
__all__ = ["PipelineContext", "build_context", "build_workflow", "run_pipeline"]
+345
View File
@@ -0,0 +1,345 @@
"""Dwie implementacje "mózgu" sieci agentowej.
`LlmBrain` - agenci agno zbudowani z definicji APM; używany w normalnym przebiegu.
`RuleBrain` - deterministyczna ścieżka bez modelu: reguły codemod z pakietu APM
plus mechaniczna kontrola jakości.
Ten sam interfejs po obu stronach daje trzy rzeczy: smoke test pipeline'u w CI bez
kosztu i dostępności GPU, powtarzalny wynik dla migracji w pełni pokrytych regułami
oraz punkt odniesienia do oceny, czy model wnosi cokolwiek ponad reguły.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Protocol
from ..apm.agno_bridge import build_agent
from ..llm import ModelFactory
from ..schemas import (
ChangePlan,
Finding,
MergeRequestDraft,
PlannedEdit,
RepoProfile,
ReviewVerdict,
VerificationResult,
)
from .codemod import apply_ruleset, load_rulesets
from .context import PipelineContext
from .facts import collect_facts, facts_as_markdown
SCHEMA_REGISTRY: dict[str, type] = {
"RepoProfile": RepoProfile,
"ChangePlan": ChangePlan,
"ReviewVerdict": ReviewVerdict,
"MergeRequestDraft": MergeRequestDraft,
}
class Brain(Protocol):
def recon(self) -> RepoProfile: ...
def plan(self, profile: RepoProfile) -> ChangePlan: ...
def implement(self, plan: ChangePlan) -> str: ...
def review(self, diff: str) -> ReviewVerdict: ...
def compose_merge_request(self, diff: str, verification: VerificationResult | None) -> MergeRequestDraft: ...
# --------------------------------------------------------------------------- LLM
class LlmBrain:
def __init__(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.model_factory = ModelFactory(ctx.settings)
self._agents: dict[str, Any] = {}
def agent(self, name: str):
if name not in self._agents:
self._agents[name] = build_agent(
definition=self.ctx.apm.agent(name),
ctx=self.ctx.apm,
settings=self.ctx.settings,
model_factory=self.model_factory,
tool_registry=self.ctx.tool_registry,
schema_registry=SCHEMA_REGISTRY,
)
return self._agents[name]
def _run(self, agent_name: str, message: str, schema: type | None = None):
self.ctx.audit.event("agent_run_started", agent=agent_name, chars=len(message))
output = self.agent(agent_name).run(message)
content = getattr(output, "content", output)
if schema is not None and not isinstance(content, schema):
content = _coerce(content, schema)
self.ctx.audit.event("agent_run_finished", agent=agent_name)
return content
# -- kroki -------------------------------------------------------------
def task_brief(self) -> str:
request = self.ctx.request
prompt = self.ctx.apm.prompt(request.prompt_name)
return prompt.render(
{
"package": request.package,
"to_version": request.to_version,
"from_version": request.from_version or "nieznana",
"repo": str(self.ctx.repo_root),
"constraints": request.constraints or "brak dodatkowych ograniczeń",
"policy": request.constraints,
}
)
def recon(self) -> RepoProfile:
facts = collect_facts(self.ctx.repo_root, self.ctx.adapter, self.ctx.request)
message = (
f"{self.task_brief()}\n\n{facts_as_markdown(facts)}\n\n"
"Zweryfikuj powyższe fakty narzędziami, uzupełnij brakujące pola i zwróć profil repozytorium."
)
profile = self._run("scout", message, RepoProfile)
# fakty deterministyczne mają pierwszeństwo nad tym, co zwrócił model
profile.build_system = facts.build_system
profile.dependency_files = facts.dependency_files
profile.declared_version = facts.declared_version or profile.declared_version
profile.verify_command = facts.verify_command
return profile
def plan(self, profile: RepoProfile) -> ChangePlan:
message = (
f"{self.task_brief()}\n\n## Profil repozytorium\n```json\n"
f"{profile.model_dump_json(indent=2)}\n```\n\n"
"Zbuduj plan zmian. Deklaracja wersji zależności zostanie ustawiona automatycznie "
"przez pipeline - nie planuj edycji plików zależności."
)
return self._run("planner", message, ChangePlan)
def implement(self, plan: ChangePlan) -> str:
edits = "\n".join(
f"{edit.order}. {edit.path} - {edit.intent} (kryterium: {edit.acceptance_criteria})"
for edit in plan.actionable_edits
)
message = (
f"{self.task_brief()}\n\n## Zatwierdzony plan\n{plan.summary}\n\n{edits}\n\n"
"Wykonaj plan pozycja po pozycji. Po każdej edycji uruchom run_verification. "
"Zakończ, gdy weryfikacja jest zielona."
)
output = self._run("coder", message)
return str(output)
def review(self, diff: str) -> ReviewVerdict:
message = (
f"{self.task_brief()}\n\n## Diff do oceny\n```diff\n{diff[:40_000]}\n```\n\n"
"Wydaj werdykt zgodnie ze swoją procedurą."
)
return self._run("reviewer", message, ReviewVerdict)
def compose_merge_request(self, diff: str, verification: VerificationResult | None) -> MergeRequestDraft:
verification_block = (
f"{verification.command} -> rc={verification.returncode}\n{verification.output_tail[-1500:]}"
if verification
else "brak weryfikacji"
)
message = (
f"{self.task_brief()}\n\n## Ślad audytowy\n"
f"run_id={self.ctx.run_id}, pakiety kontekstu={self.ctx.apm.packages or ['local']}, "
f"lock={self.ctx.apm.lockfile_hash}, modele={self.ctx.settings.models.as_dict()}\n\n"
f"## Wynik weryfikacji\n```\n{verification_block}\n```\n\n"
f"## Diff\n```diff\n{diff[:30_000]}\n```"
)
return self._run("scribe", message, MergeRequestDraft)
def _coerce(content: Any, schema: type):
"""Model bywa gadatliwy mimo output_schema - ostatnia linia obrony przed śmieciem."""
if isinstance(content, schema):
return content
if isinstance(content, dict):
return schema(**content)
text = str(content).strip()
if text.startswith("```"):
text = text.strip("`")
text = text.split("\n", 1)[1] if "\n" in text else text
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
return schema(**json.loads(text[start : end + 1]))
raise ValueError(f"Nie udało się sparsować odpowiedzi agenta do schematu {schema.__name__}: {text[:300]}")
# -------------------------------------------------------------------------- reguły
class RuleBrain:
"""Ścieżka bez modelu: reguły codemod + mechaniczne kontrole."""
def __init__(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self._rulesets = self._load_rulesets()
def _load_rulesets(self):
package = self.ctx.request.package
if not package:
return []
skill_paths = [ref.path for ref in self.ctx.apm.skills.values()]
return load_rulesets(skill_paths, package)
def recon(self) -> RepoProfile:
profile = collect_facts(self.ctx.repo_root, self.ctx.adapter, self.ctx.request)
if not self._rulesets:
profile.gaps.append(
f"Brak reguł codemod dla pakietu '{self.ctx.request.package}' w kontekście APM - "
"tryb offline nie zmigruje kodu."
)
return profile
def plan(self, profile: RepoProfile) -> ChangePlan:
edits: list[PlannedEdit] = []
order = 1
for ruleset in self._rulesets:
preview = apply_ruleset(self.ctx.repo_root, ruleset, dry_run=True)
for path in preview.changed_files:
edits.append(
PlannedEdit(
order=order,
path=path,
intent=f"Zastosuj reguły migracyjne pakietu {ruleset.package}",
rationale=f"Reguły z {ruleset.source.name if ruleset.source else 'APM'}",
acceptance_criteria=f"{profile.verify_command} kończy się kodem 0",
)
)
order += 1
risks = ["Tryb deterministyczny: zmiany spoza zakresu reguł nie zostaną wykonane."]
blocked = (
[
PlannedEdit(
order=999,
path="(cały projekt)",
intent="Przypadki nieobjęte regułami codemod",
requires_human=True,
blocked_reason="Tryb offline nie używa modelu - nietypowe użycia API wymagają przebiegu z LLM.",
)
]
if not self._rulesets
else []
)
return ChangePlan(
summary=(
f"Migracja {self.ctx.request.package} -> {self.ctx.request.to_version} "
f"regułami codemod z pakietu APM ({len(edits)} plików)."
),
edits=edits + blocked,
out_of_scope=["Pliki zależności (ustawiane deterministycznie przez pipeline)"],
risks=risks,
)
def implement(self, plan: ChangePlan) -> str:
summary: list[str] = []
for ruleset in self._rulesets:
result = apply_ruleset(self.ctx.repo_root, ruleset)
for path in result.changed_files:
self.ctx.workspace.changed_files.add(path)
self.ctx.audit.record(
"codemod",
{"ruleset": str(ruleset.source), "package": ruleset.package},
detail=f"{result.total_replacements} podmian w {len(result.changed_files)} plikach",
)
summary.append(
f"{ruleset.package}: {result.total_replacements} podmian w {len(result.changed_files)} plikach; "
f"reguły bez dopasowania: {', '.join(result.remaining_rules) or 'brak'}"
)
return "\n".join(summary) or "Brak reguł do zastosowania."
def review(self, diff: str) -> ReviewVerdict:
findings: list[Finding] = []
changed = self.ctx.git.changed_files()
for path in changed:
if any(Path(path).match(pattern) for pattern in self.ctx.settings.deny_globs):
findings.append(
Finding(
severity="blocker",
category="guardrail",
path=path,
message="Zmieniono plik objęty zakazem modyfikacji.",
)
)
if "/tests/" in f"/{path}" or Path(path).name.startswith("test_"):
findings.append(
Finding(
severity="warning",
category="test_change",
path=path,
message="Zmiana w pliku testowym - wymaga uwagi recenzenta.",
)
)
if len(changed) > self.ctx.settings.max_files_changed:
findings.append(
Finding(
severity="blocker", category="scope", message=f"Zmieniono {len(changed)} plików - powyżej budżetu."
)
)
last = self.ctx.verification.results[-1] if self.ctx.verification.results else None
if last is None or not last.passed:
findings.append(
Finding(
severity="blocker", category="verification", message="Weryfikacja nie zakończyła się powodzeniem."
)
)
blockers = [f for f in findings if f.severity == "blocker"]
return ReviewVerdict(
verdict="request_changes" if blockers else "approve",
summary=(
"Kontrola mechaniczna bez zastrzeżeń blokujących." if not blockers else "Wykryto problemy blokujące."
),
findings=findings,
)
def compose_merge_request(self, diff: str, verification: VerificationResult | None) -> MergeRequestDraft:
request = self.ctx.request
changed = self.ctx.git.changed_files()
verification_line = (
f"`{verification.command}` -> rc={verification.returncode} ({verification.duration_s}s)"
if verification
else "brak weryfikacji"
)
description = (
f"""## Co i dlaczego
Automatyczna migracja `{request.package}` do wersji `{request.to_version}` wykonana przez pipeline
agentowy w trybie deterministycznym (reguły codemod z pakietu APM, bez udziału modelu językowego).
## Zakres zmian
"""
+ "\n".join(f"- `{path}`" for path in changed)
+ f"""
## Weryfikacja
{verification_line}
## Ryzyko i ograniczenia
- Tryb deterministyczny obejmuje wyłącznie przypadki opisane regułami codemod.
- Nietypowe użycia API mogły nie zostać wykryte - wymagana uwaga recenzenta.
## Ślad audytowy
- run_id: `{self.ctx.run_id}`
- kontekst APM: `{", ".join(self.ctx.apm.packages) or "local"}` (lock: `{self.ctx.apm.lockfile_hash}`)
- tryb: deterministyczny (bez LLM)
## Lista kontrolna dla recenzenta
- [ ] Diff nie zawiera zmian spoza zakresu
- [ ] Brak zmian w testach maskujących błąd
- [ ] Wersja zależności zgodna z zadaniem
"""
)
return MergeRequestDraft(
title=f"build(deps): {request.package} -> {request.to_version} wraz z migracją API",
description=description,
)
def build_brain(ctx: PipelineContext) -> Brain:
return RuleBrain(ctx) if ctx.mode == "offline" else LlmBrain(ctx)
+102
View File
@@ -0,0 +1,102 @@
"""Deterministyczny silnik reguł migracyjnych.
Reguły 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
+118
View File
@@ -0,0 +1,118 @@
"""Kontekst przebiegu - wszystko, co kroki pipeline'u współdzielą."""
from __future__ import annotations
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from ..adapters import EcosystemAdapter, detect_adapter
from ..apm.loader import ApmContext, load_apm_context
from ..config import Settings
from ..observability.audit import AuditLog
from ..schemas import ChangePlan, ChangeRequest, MergeRequestDraft, RepoProfile, ReviewVerdict, RunManifest
from ..tools import GitRepo, VerificationRunner, WorkspaceTools, build_tool_registry
IGNORED_ON_COPY = shutil.ignore_patterns(".git", "__pycache__", ".venv", "node_modules", ".pytest_cache", ".runs")
@dataclass
class PipelineContext:
request: ChangeRequest
settings: Settings
run_id: str
run_dir: Path
repo_root: Path
apm: ApmContext
audit: AuditLog
adapter: EcosystemAdapter
workspace: WorkspaceTools
verification: VerificationRunner
git: GitRepo
manifest: RunManifest
mode: str = "llm"
plan_only: bool = False
profile: RepoProfile | None = None
plan: ChangePlan | None = None
review: ReviewVerdict | None = None
merge_request: MergeRequestDraft | None = None
notes: list[str] = field(default_factory=list)
# ustawiane po zbudowaniu kontekstu (unika cyklicznego importu)
brain: object | None = None
@property
def tool_registry(self):
return build_tool_registry(self.workspace, self.verification)
def note(self, message: str) -> None:
self.notes.append(message)
self.audit.event("note", message=message)
def prepare_workspace(repo_path: Path, run_dir: Path, in_place: bool) -> Path:
"""Zwraca katalog, na którym pracuje sieć agentowa.
Domyślnie kopia - repozytorium źródłowe zostaje nietknięte także wtedy, gdy przebieg
zakończy się błędem w połowie edycji. W GitLab CI (`--in-place`) job i tak ma własny,
jednorazowy checkout, więc kopiowanie byłoby stratą czasu.
"""
repo_path = Path(repo_path).resolve()
if in_place:
return repo_path
target = Path(run_dir).resolve() / "workspace"
if target.exists():
shutil.rmtree(target)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(repo_path, target, ignore=IGNORED_ON_COPY)
return target
def build_context(
request: ChangeRequest,
settings: Settings,
run_id: str,
mode: str = "llm",
in_place: bool = False,
plan_only: bool = False,
verify_command: list[str] | None = None,
) -> PipelineContext:
run_dir = Path(settings.run_dir).resolve()
run_dir.mkdir(parents=True, exist_ok=True)
audit = AuditLog(run_dir=run_dir)
apm = load_apm_context(settings.apm_root)
audit.event("apm_context_loaded", **{k: str(v) for k, v in apm.summary().items()})
repo_root = prepare_workspace(Path(request.repo_path), run_dir, in_place)
adapter = detect_adapter(repo_root)
workspace = WorkspaceTools(repo_root, settings, audit)
verification = VerificationRunner(repo_root, adapter, settings, audit, command=verify_command)
git = GitRepo(repo_root)
git.init_if_needed()
manifest = RunManifest(
run_id=run_id,
mode="offline" if mode == "offline" else "llm",
request=request,
models=settings.models.as_dict() if mode != "offline" else {},
apm_context=apm.summary(),
)
return PipelineContext(
request=request,
settings=settings,
run_id=run_id,
run_dir=run_dir,
repo_root=repo_root,
apm=apm,
audit=audit,
adapter=adapter,
workspace=workspace,
verification=verification,
git=git,
manifest=manifest,
mode=mode,
plan_only=plan_only,
)
+86
View File
@@ -0,0 +1,86 @@
"""Deterministyczne ustalanie faktów o repozytorium.
Fakty, które da się ustalić kodem, ustalamy kodem. Model dostaje je jako dane wejściowe,
a nie jako zadanie do rozwiązania. To skraca przebieg i eliminuje całą klasę halucynacji
("pewnie testy uruchamia się przez tox").
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from ..adapters.base import EcosystemAdapter
from ..schemas import ChangeRequest, RepoProfile
def _grep(root: Path, pattern: str) -> list[str]:
proc = subprocess.run(
[
"grep",
"-rlE",
"--include=*.py",
"--include=*.java",
"--include=*.kt",
"--include=*.js",
"--include=*.ts",
"--exclude-dir=.git",
"--exclude-dir=__pycache__",
"--exclude-dir=.venv",
"--exclude-dir=node_modules",
pattern,
".",
],
cwd=root,
capture_output=True,
text=True,
timeout=60,
)
return sorted({line.lstrip("./") for line in proc.stdout.splitlines() if line})
def collect_facts(root: Path, adapter: EcosystemAdapter, request: ChangeRequest) -> RepoProfile:
package = request.package or ""
module = request.module_name or (adapter.module_name(package) if package else "")
profile = RepoProfile(
build_system=adapter.name,
dependency_files=[p.relative_to(root).as_posix() for p in adapter.dependency_files()],
module_name=module or None,
verify_command=" ".join(adapter.verify_command()),
)
if package:
profile.declared_version = adapter.read_declared_version(package)
if profile.declared_version is None:
profile.gaps.append(
f"Nie znaleziono deklaracji pakietu '{package}' w plikach zależności: "
+ ", ".join(profile.dependency_files)
)
if module:
usage_files = _grep(root, rf"(^|[^A-Za-z0-9_]){module}([^A-Za-z0-9_]|$)")
profile.usage_files = [f for f in usage_files if not f.startswith("stubs/")]
profile.blast_radius = (
"low" if len(profile.usage_files) <= 2 else "medium" if len(profile.usage_files) <= 8 else "high"
)
else:
profile.gaps.append("Nie ustalono nazwy modułu importu - podaj --module.")
return profile
def facts_as_markdown(profile: RepoProfile) -> str:
lines = [
"## Ustalone fakty o repozytorium (deterministycznie, nie zgadywane)",
f"- System budowania: {profile.build_system}",
f"- Pliki zależności: {', '.join(profile.dependency_files) or 'brak'}",
f"- Deklarowana wersja pakietu: {profile.declared_version or 'nie znaleziono'}",
f"- Moduł importu: {profile.module_name or 'nieustalony'}",
f"- Komenda weryfikacji: {profile.verify_command or 'nieustalona'}",
f"- Pliki z użyciem modułu ({len(profile.usage_files)}): {', '.join(profile.usage_files[:25]) or 'brak'}",
f"- Promień rażenia: {profile.blast_radius}",
]
if profile.gaps:
lines.append("- Luki wymagające uwagi: " + "; ".join(profile.gaps))
return "\n".join(lines)
+62
View File
@@ -0,0 +1,62 @@
"""Topologia sieci agentowej wyrażona jako agno Workflow."""
from __future__ import annotations
from agno.workflow import Condition, Loop, Step, Workflow
from .context import PipelineContext
from .steps import (
make_bump_dependency,
make_compose_merge_request,
make_implement,
make_intake,
make_plan,
make_recon,
make_review,
make_should_apply,
make_verification_green,
make_verify,
)
def build_workflow(ctx: PipelineContext) -> Workflow:
"""Buduje przepływ:
intake -> recon -> plan
|
+-- [warunek: jest co wdrażać i nie jest to plan-only]
bump-dependency
loop( implement -> verify ) # do zieleni lub limitu iteracji
review
compose-merge-request
Publikacja (branch, commit, push, MR) jest celowo POZA workflow - to jedyny krok
z efektem ubocznym poza katalogiem roboczym i podlega osobnej bramce w GitLab CI.
"""
apply_branch = [
Step(name="bump-dependency", executor=make_bump_dependency(ctx)),
Loop(
name="implement",
steps=[
Step(name="code", executor=make_implement(ctx)),
Step(name="verify", executor=make_verify(ctx)),
],
end_condition=make_verification_green(ctx),
max_iterations=ctx.settings.max_iterations,
),
Step(name="review", executor=make_review(ctx)),
Step(name="compose-merge-request", executor=make_compose_merge_request(ctx)),
]
return Workflow(
name="agentic-codemod",
description="Sieć agentowa modyfikująca kod na podstawie kontekstu z pakietów APM",
steps=[
Step(name="intake", executor=make_intake(ctx)),
Step(name="recon", executor=make_recon(ctx)),
Step(name="plan", executor=make_plan(ctx)),
Condition(name="apply-changes", evaluator=make_should_apply(ctx), steps=apply_branch),
],
store_events=False,
telemetry=False,
)
+111
View File
@@ -0,0 +1,111 @@
"""Uruchamianie przebiegu end-to-end."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from uuid import uuid4
from ..config import Settings
from ..integrations.gitlab import GitLabClient
from ..schemas import ChangeRequest, RunManifest
from .brains import build_brain
from .context import build_context
from .network import build_workflow
from .steps import dump_manifest
def new_run_id() -> str:
return f"{datetime.now(timezone.utc):%Y%m%d-%H%M%S}-{uuid4().hex[:6]}"
def _slug(value: str) -> str:
return re.sub(r"[^a-z0-9.-]+", "-", value.lower()).strip("-")
def run_pipeline(
request: ChangeRequest,
settings: Settings | None = None,
mode: str = "llm",
in_place: bool = False,
plan_only: bool = False,
publish: bool = False,
target_branch: str = "main",
verify_command: list[str] | None = None,
run_id: str | None = None,
) -> RunManifest:
settings = settings or Settings.from_env()
run_id = run_id or new_run_id()
ctx = build_context(
request=request,
settings=settings,
run_id=run_id,
mode=mode,
in_place=in_place,
plan_only=plan_only,
verify_command=verify_command,
)
ctx.brain = build_brain(ctx)
workflow = build_workflow(ctx)
try:
workflow.run(input=f"{request.task_type.value}:{request.package}->{request.to_version}")
except Exception as exc: # przebieg bez nadzoru: błąd musi wylądować w manifeście, nie tylko w logu
ctx.manifest.errors.append(f"{type(exc).__name__}: {exc}")
ctx.manifest.status = "failed"
ctx.audit.event("pipeline_error", error=str(exc)[:500])
dump_manifest(ctx)
raise
changed = ctx.git.changed_files()
last_verification = ctx.verification.results[-1] if ctx.verification.results else None
if plan_only:
ctx.manifest.status = "success"
elif not changed:
ctx.manifest.status = "no_changes"
elif last_verification is None or not last_verification.passed:
ctx.manifest.status = "failed"
elif ctx.review and ctx.review.verdict != "approve":
ctx.manifest.status = "blocked"
else:
ctx.manifest.status = "success"
if publish and ctx.manifest.status == "success" and ctx.merge_request:
_publish(ctx, target_branch)
ctx.manifest.finished_at = datetime.now(timezone.utc)
dump_manifest(ctx)
return ctx.manifest
def _publish(ctx, target_branch: str) -> None:
"""Gałąź + commit + MR. Jedyny krok z efektem poza katalogiem roboczym."""
request = ctx.request
branch = f"codemod/{_slug(request.package or 'change')}-{_slug(request.to_version or '')}-{ctx.run_id[-6:]}"
draft = ctx.merge_request
ctx.git.create_branch(branch)
ctx.git.commit_all(f"{draft.title}\n\nRun-Id: {ctx.run_id}\nGenerated-By: agentic-codemod")
try:
ctx.git.push(branch=branch)
except Exception as exc:
ctx.manifest.errors.append(f"push nieudany: {exc}")
ctx.audit.event("push_failed", error=str(exc)[:300])
return
client = GitLabClient(ctx.settings)
ref = client.create_merge_request(
source_branch=branch,
target_branch=target_branch,
title=draft.title,
description=draft.description,
)
ctx.audit.event("merge_request", created=ref.created, url=ref.web_url or "", detail=ref.detail)
if ref.web_url:
ctx.notes.append(f"MR: {ref.web_url}")
elif ref.detail:
ctx.manifest.errors.append(ref.detail)
if request.issue_ref and ref.web_url:
client.comment_on_issue(request.issue_ref, f"Pipeline agentowy przygotował zmianę: {ref.web_url}")
+176
View File
@@ -0,0 +1,176 @@
"""Kroki pipeline'u jako funkcje agno (`Step(executor=...)`).
Podział odpowiedzialności jest celowy:
- krok deterministyczny robi to, co można zrobić bez modelu (bump wersji, weryfikacja, git),
- krok "mózgowy" deleguje do `Brain` (agent agno albo reguły codemod).
Dzięki temu topologia przepływu jest czytelna i identyczna w obu trybach, a różnica
sprowadza się do implementacji mózgu.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from agno.workflow import StepInput, StepOutput
from ..schemas import ChangePlan
from .context import PipelineContext
Executor = Callable[[StepInput], StepOutput]
def make_intake(ctx: PipelineContext) -> Executor:
def intake(step_input: StepInput) -> StepOutput:
request = ctx.request
ctx.audit.event(
"intake",
package=request.package,
to_version=request.to_version,
repo=str(ctx.repo_root),
adapter=ctx.adapter.name,
mode=ctx.mode,
)
return StepOutput(
content=(
f"Zadanie: {request.task_type.value} | pakiet={request.package} "
f"-> {request.to_version} | ekosystem={ctx.adapter.name} | tryb={ctx.mode}"
),
success=True,
)
return intake
def make_recon(ctx: PipelineContext) -> Executor:
def recon(step_input: StepInput) -> StepOutput:
profile = ctx.brain.recon() # type: ignore[union-attr]
ctx.profile = profile
ctx.manifest.profile = profile
(ctx.run_dir / "profile.json").write_text(profile.model_dump_json(indent=2), encoding="utf-8")
return StepOutput(content=profile.model_dump_json(indent=2), success=True)
return recon
def make_plan(ctx: PipelineContext) -> Executor:
def plan(step_input: StepInput) -> StepOutput:
assert ctx.profile is not None
change_plan: ChangePlan = ctx.brain.plan(ctx.profile) # type: ignore[union-attr]
ctx.plan = change_plan
ctx.manifest.plan = change_plan
(ctx.run_dir / "plan.json").write_text(change_plan.model_dump_json(indent=2), encoding="utf-8")
for edit in change_plan.blocked_edits:
ctx.note(f"requires_human: {edit.path} - {edit.blocked_reason or edit.intent}")
return StepOutput(content=change_plan.model_dump_json(indent=2), success=True)
return plan
def make_bump_dependency(ctx: PipelineContext) -> Executor:
"""Deklarację wersji ustawia kod, nie model - to operacja w 100% deterministyczna."""
def bump(step_input: StepInput) -> StepOutput:
request = ctx.request
if not request.package or not request.to_version:
return StepOutput(content="Pominięto bump wersji (brak pakietu lub wersji docelowej).", success=True)
changed = ctx.adapter.set_declared_version(request.package, request.to_version)
relative = [p.relative_to(ctx.repo_root).as_posix() for p in changed]
for path in relative:
ctx.workspace.changed_files.add(path)
ctx.audit.record(
"bump_dependency",
{"package": request.package, "version": request.to_version},
ok=bool(relative),
detail=", ".join(relative) or "brak zmian",
)
if not relative:
ctx.note(f"Nie znaleziono deklaracji '{request.package}' do podbicia - sprawdź profil repozytorium.")
return StepOutput(content=f"Zaktualizowane pliki zależności: {', '.join(relative) or 'brak'}", success=True)
return bump
def make_implement(ctx: PipelineContext) -> Executor:
def implement(step_input: StepInput) -> StepOutput:
assert ctx.plan is not None
ctx.manifest.iterations += 1
result = ctx.brain.implement(ctx.plan) # type: ignore[union-attr]
return StepOutput(content=str(result), success=True)
return implement
def make_verify(ctx: PipelineContext) -> Executor:
def verify(step_input: StepInput) -> StepOutput:
result = ctx.verification.run()
ctx.manifest.verifications.append(result)
return StepOutput(
content=f"passed={result.passed} rc={result.returncode}\n{result.output_tail[-2000:]}",
success=result.passed,
)
return verify
def make_review(ctx: PipelineContext) -> Executor:
def review(step_input: StepInput) -> StepOutput:
diff = ctx.git.diff()
verdict = ctx.brain.review(diff) # type: ignore[union-attr]
ctx.review = verdict
ctx.manifest.review = verdict
(ctx.run_dir / "review.json").write_text(verdict.model_dump_json(indent=2), encoding="utf-8")
return StepOutput(content=verdict.model_dump_json(indent=2), success=verdict.verdict == "approve")
return review
def make_compose_merge_request(ctx: PipelineContext) -> Executor:
def compose(step_input: StepInput) -> StepOutput:
diff = ctx.git.diff()
last = ctx.verification.results[-1] if ctx.verification.results else None
draft = ctx.brain.compose_merge_request(diff, last) # type: ignore[union-attr]
ctx.merge_request = draft
ctx.manifest.merge_request = draft
(ctx.run_dir / "merge_request.md").write_text(f"# {draft.title}\n\n{draft.description}", encoding="utf-8")
(ctx.run_dir / "changes.patch").write_text(diff, encoding="utf-8")
return StepOutput(content=draft.title, success=True)
return compose
# --------------------------------------------------------------- predykaty
def make_should_apply(ctx: PipelineContext) -> Callable[[StepInput], bool]:
def should_apply(step_input: StepInput) -> bool:
if ctx.plan_only:
ctx.note("Tryb plan-only: pominięto modyfikację kodu.")
return False
if ctx.plan is None or not ctx.plan.actionable_edits:
ctx.note("Plan nie zawiera wykonalnych pozycji - nie ma czego wdrażać.")
return False
return True
return should_apply
def make_verification_green(ctx: PipelineContext) -> Callable[[list[StepOutput]], bool]:
def verification_green(outputs: list[StepOutput]) -> bool:
results = ctx.verification.results
if not results:
return False
if results[-1].passed:
return True
ctx.note(f"Iteracja {ctx.manifest.iterations}: weryfikacja czerwona, ponawiam.")
return False
return verification_green
def dump_manifest(ctx: PipelineContext) -> None:
ctx.manifest.changed_files = ctx.git.changed_files()
ctx.manifest.tool_calls = ctx.audit.entries
ctx.manifest.errors.extend(n for n in ctx.notes if n.startswith("requires_human"))
(ctx.run_dir / "run.json").write_text(
json.dumps(json.loads(ctx.manifest.model_dump_json()), ensure_ascii=False, indent=2), encoding="utf-8"
)