feat: initial commit
This commit is contained in:
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user