"""Atrapa serwera zgodnego z OpenAI - do testowania ścieżki agentowej bez modelu. Po co to jest ------------ Tryb `--offline` sprawdza `RuleBrain`, ale nie dotyka tego, co w tym projekcie jest najbardziej kruche: budowy agentów z definicji APM, wywoływania narzędzi przez model i parsowania strukturalnych wyjść. Ta atrapa domyka lukę - pozwala przepuścić `LlmBrain` przez pełny przebieg na każdym MR, na Linuksie i na macOS, bez GPU i bez pobierania modelu. Czym to NIE jest ---------------- To nie jest symulator modelu. Scenariusze są zestrojone z fixture'em `acme-app`: atrapa odpowiada z góry ustalonymi wywołaniami narzędzi i strukturami. Sprawdza, czy instalacja hydrauliczna trzyma wodę - nie czy model jest mądry. Routing odpowiedzi po nazwie agenta z komunikatu systemowego ("Your name is: coder."), którą wstawia agno przy `add_name_to_context=True`. Uruchomienie: python3 llm/mock_server.py --port 8077 """ from __future__ import annotations import argparse import json import re import time import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any MODEL_ID = "mock/agentic-codemod" _NAME_RE = re.compile(r"Your name is:\s*([a-z0-9_-]+)", re.IGNORECASE) # --------------------------------------------------------------------------- scenariusze REPO_PROFILE = { "build_system": "python-pip", "dependency_files": ["pyproject.toml"], "declared_version": "==1.4.2", "module_name": "acme", "usage_files": ["src/acme_app/notifier.py"], "usage_symbols": ["Client", "send", "close"], "verify_command": "python -m pytest -q", "blast_radius": "low", "gaps": [], } CHANGE_PLAN = { "summary": "Migracja acme-sdk 1.4.2 -> 2.1.0: zmiana klasy klienta, wysyłki i odczytu odpowiedzi.", "edits": [ { "order": 1, "path": "src/acme_app/notifier.py", "intent": "Client -> AcmeClient, send() -> messages.create(), usunięcie close(), result['id'] -> result.id", "rationale": "Jedyny plik używający SDK; zmiany opisane w notatce migracyjnej 2.x.", "acceptance_criteria": "pytest kończy się kodem 0, brak wystąpień client.send( i .close()", "requires_human": False, "blocked_reason": None, } ], "out_of_scope": ["pyproject.toml (wersję ustawia pipeline)", "stubs/acme (atrapa dostawcy)"], "risks": ["Brak testów integracyjnych z realnym dostawcą."], } REVIEW_VERDICT = { "verdict": "approve", "summary": "Diff ograniczony do jednego pliku i zgodny z planem; testy nietknięte.", "findings": [ { "severity": "info", "category": "scope", "path": "src/acme_app/notifier.py", "message": "Zmiany wyłącznie w warstwie integracji z SDK.", } ], } MERGE_REQUEST = { "title": "build(deps): acme-sdk 1.4.2 -> 2.1.0 wraz z migracją API", "description": ( "## Co i dlaczego\n\n" "Podniesienie acme-sdk do 2.1.0 i dostosowanie wywołań do API 2.x.\n\n" "## Zakres zmian\n\n- `pyproject.toml` - deklaracja wersji\n" "- `src/acme_app/notifier.py` - migracja klienta i wysyłki\n\n" "## Weryfikacja\n\n`pytest -q` -> rc=0\n\n" "## Ryzyko i ograniczenia\n\n" "Przebieg wykonany na atrapie modelu - opis służy weryfikacji pipeline'u, nie ocenie zmiany.\n" ), } # Fragmenty zestrojone z examples/fixtures/acme-app - atrapa "wie", co zastąpić. _WELCOME_OLD = ( " client = Client(api_key=api_key, endpoint=endpoint)\n" " result = client.send(to=email, body=WELCOME_BODY)\n" " client.close()\n" ' return result["id"]' ) _WELCOME_NEW = ( " client = AcmeClient(api_key=api_key, base_url=endpoint)\n" " result = client.messages.create(recipient=email, content=WELCOME_BODY)\n" " return result.id" ) _REMINDER_OLD = ( " client = Client(api_key=api_key, endpoint=endpoint)\n" " try:\n" " result = client.send(to=email, body=body)\n" " except AcmeError:\n" " return None\n" " client.close()\n" ' return result["id"]' ) _REMINDER_NEW = ( " client = AcmeClient(api_key=api_key, base_url=endpoint)\n" " try:\n" " result = client.messages.create(recipient=email, content=body)\n" " except AcmeError:\n" " return None\n" " return result.id" ) CODER_SCRIPT: list[dict[str, Any]] = [ {"tool": "read_file", "args": {"path": "src/acme_app/notifier.py"}}, { "tool": "replace_in_file", "args": { "path": "src/acme_app/notifier.py", "old_text": "from acme import Client", "new_text": "from acme import AcmeClient", }, }, { "tool": "replace_in_file", "args": {"path": "src/acme_app/notifier.py", "old_text": _WELCOME_OLD, "new_text": _WELCOME_NEW}, }, { "tool": "replace_in_file", "args": {"path": "src/acme_app/notifier.py", "old_text": _REMINDER_OLD, "new_text": _REMINDER_NEW}, }, {"tool": "run_verification", "args": {}}, { "content": ( "Plan wykonany. Zmieniony plik: src/acme_app/notifier.py " "(import, konstruktor klienta, wysyłka, odczyt identyfikatora). " "Weryfikacja zakończona powodzeniem." ) }, ] def _agent_name(messages: list[dict[str, Any]]) -> str: for message in messages: content = message.get("content") if isinstance(content, str): match = _NAME_RE.search(content) if match: return match.group(1).lower() return "unknown" def _completed_tool_steps(messages: list[dict[str, Any]]) -> int: return sum(1 for m in messages if m.get("role") == "assistant" and m.get("tool_calls")) def _tool_call_message(step: dict[str, Any]) -> dict[str, Any]: return { "role": "assistant", "content": None, "tool_calls": [ { "id": f"call_{uuid.uuid4().hex[:12]}", "type": "function", "function": {"name": step["tool"], "arguments": json.dumps(step["args"], ensure_ascii=False)}, } ], } def build_message(messages: list[dict[str, Any]]) -> tuple[dict[str, Any], str]: """Zwraca (wiadomość asystenta, finish_reason) dla danej rozmowy.""" name = _agent_name(messages) if name == "coder": index = _completed_tool_steps(messages) if index >= len(CODER_SCRIPT): return {"role": "assistant", "content": "Zakończono."}, "stop" step = CODER_SCRIPT[index] if "tool" in step: return _tool_call_message(step), "tool_calls" return {"role": "assistant", "content": step["content"]}, "stop" payloads = { "scout": REPO_PROFILE, "planner": CHANGE_PLAN, "reviewer": REVIEW_VERDICT, "scribe": MERGE_REQUEST, } if name in payloads: return {"role": "assistant", "content": json.dumps(payloads[name], ensure_ascii=False)}, "stop" if name == "unknown" and not any(m.get("role") == "system" for m in messages): # zwykłe zapytanie diagnostyczne (task llm:smoke), a nie agent z pipeline'u return {"role": "assistant", "content": "dziala (atrapa LLM)"}, "stop" return { "role": "assistant", "content": f"Atrapa LLM nie ma scenariusza dla agenta '{name}'. Uzupełnij llm/mock_server.py.", }, "stop" class MockHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "agentic-codemod-mock/1.0" def log_message(self, fmt: str, *args: Any) -> None: # cichy log, chyba że --verbose if getattr(self.server, "verbose", False): super().log_message(fmt, *args) # ------------------------------------------------------------------ util def _send(self, payload: dict[str, Any], status: int = 200) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) # ------------------------------------------------------------------ HTTP def do_GET(self) -> None: if self.path.rstrip("/") in ("/healthz", "/v1/models", "/models"): if "models" in self.path: self._send({"object": "list", "data": [{"id": MODEL_ID, "object": "model", "owned_by": "mock"}]}) else: self._send({"status": "ok", "model": MODEL_ID}) return self._send({"error": {"message": f"nieobsługiwana ścieżka {self.path}"}}, status=404) def do_POST(self) -> None: if not self.path.rstrip("/").endswith("/chat/completions"): self._send({"error": {"message": f"nieobsługiwana ścieżka {self.path}"}}, status=404) return length = int(self.headers.get("Content-Length", "0")) request = json.loads(self.rfile.read(length) or b"{}") if request.get("stream"): # agno w tym pipelinie nie streamuje; jawny błąd jest lepszy niż ciche milczenie self._send({"error": {"message": "atrapa nie obsługuje stream=true"}}, status=400) return messages = request.get("messages") or [] message, finish_reason = build_message(messages) # Jedna zwięzła linia na żądanie - dzięki temu `task llm:logs` pokazuje przebieg # rozmowy z agentami także na atrapie, a nie tylko przy realnym backendzie. tool = "" if message.get("tool_calls"): tool = " -> " + ", ".join(c["function"]["name"] for c in message["tool_calls"]) print( f"[mock] agent={_agent_name(messages):<9} wiadomości={len(messages):<3} finish={finish_reason}{tool}", flush=True, ) self._send( { "id": f"chatcmpl-{uuid.uuid4().hex[:16]}", "object": "chat.completion", "created": int(time.time()), "model": request.get("model") or MODEL_ID, "choices": [{"index": 0, "message": message, "finish_reason": finish_reason, "logprobs": None}], "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, } ) def serve(host: str = "127.0.0.1", port: int = 8077, verbose: bool = False) -> ThreadingHTTPServer: httpd = ThreadingHTTPServer((host, port), MockHandler) httpd.verbose = verbose # type: ignore[attr-defined] httpd.daemon_threads = True return httpd def main() -> None: parser = argparse.ArgumentParser(description="Atrapa serwera OpenAI dla testów ścieżki agentowej") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8077) parser.add_argument("--verbose", action="store_true") args = parser.parse_args() httpd = serve(args.host, args.port, args.verbose) print(f"atrapa LLM: http://{args.host}:{args.port}/v1 (model: {MODEL_ID})", flush=True) try: httpd.serve_forever() except KeyboardInterrupt: pass finally: httpd.shutdown() if __name__ == "__main__": main()