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