refactor: restructure repo layout
This commit is contained in:
209
mcp/qwen3-mcp/agent_lm.py
Normal file
209
mcp/qwen3-mcp/agent_lm.py
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env -S .claude/venv/bin/python3
|
||||
"""
|
||||
Agent loop local (~120 lignes) pour interagir avec LM Studio via l'API OpenAI-compatible.
|
||||
Usage : python3 .claude/mcp/qwen3-mcp/agent_lm.py --task "..." --files file1.py file2.go
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
SERVER = "http://localhost"
|
||||
PORT = 8888 # 1248
|
||||
LMSTUDIO_URL = f"{SERVER}:{PORT}/v1/chat/completions"
|
||||
LMSTUDIO_MODELS_URL = f"{SERVER}:{PORT}/v1/models"
|
||||
DEFAULT_MODEL = "qwen/qwen3-coder-next"
|
||||
|
||||
SYSTEM_PROMPT = """Tu es un assistant de codage spécialisé. Tu effectues des tâches atomiques sur des fichiers source.
|
||||
Règles :
|
||||
- Ne modifie que ce qui est demandé
|
||||
- Ne change pas les signatures publiques (traits Rust, interfaces Go exportées)
|
||||
- Retourne uniquement le code, sans explication ni markdown
|
||||
- Si tu ne peux pas accomplir la tâche, réponds avec: ERROR: <raison>
|
||||
"""
|
||||
|
||||
|
||||
def list_models() -> list[str]:
|
||||
"""Retourne la liste des modèles disponibles dans LM Studio."""
|
||||
try:
|
||||
resp = requests.get(LMSTUDIO_MODELS_URL, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return [m["id"] for m in resp.json().get("data", [])]
|
||||
except requests.RequestException as e:
|
||||
print(f"Impossible de contacter LM Studio : {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
def read_files(paths: list[str]) -> str:
|
||||
"""Lit les fichiers et les formate pour le prompt."""
|
||||
context = ""
|
||||
for path in paths:
|
||||
try:
|
||||
content = Path(path).read_text(encoding="utf-8")
|
||||
context += f"=== {path} ===\n{content}\n\n"
|
||||
except OSError as e:
|
||||
context += f"=== {path} === ERREUR: {e}\n\n"
|
||||
return context
|
||||
|
||||
|
||||
def call_lmstudio(task: str, file_context: str, model: str) -> str:
|
||||
"""Envoie la tâche à LM Studio et retourne la réponse."""
|
||||
user_message = (
|
||||
f"TÂCHE: {task}\n\nFICHIERS:\n{file_context}"
|
||||
if file_context
|
||||
else f"TÂCHE: {task}"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(LMSTUDIO_URL, json=payload, timeout=120)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
except requests.Timeout:
|
||||
return "ERROR: LM Studio timeout (>120s)"
|
||||
except requests.RequestException as e:
|
||||
return f"ERROR: {e}"
|
||||
except (KeyError, IndexError) as e:
|
||||
return f"ERROR: réponse inattendue de LM Studio : {e}"
|
||||
|
||||
|
||||
def write_file(path: str, content: str) -> bool:
|
||||
"""Écrit le contenu dans un fichier, crée les répertoires si nécessaire."""
|
||||
try:
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(path).write_text(content, encoding="utf-8")
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"Erreur écriture {path}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def run_lint(file_path: str) -> tuple[bool, str]:
|
||||
"""Lance le linter approprié selon l'extension."""
|
||||
import subprocess
|
||||
|
||||
if file_path.endswith(".rs"):
|
||||
result = subprocess.run(
|
||||
["cargo", "clippy", "--quiet"], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
elif file_path.endswith(".go"):
|
||||
result = subprocess.run(
|
||||
["golangci-lint", "run", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
else:
|
||||
return True, "no linter for this file type"
|
||||
|
||||
passed = result.returncode == 0
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
return passed, output
|
||||
|
||||
|
||||
def agent_loop(
|
||||
task: str,
|
||||
input_files: list[str],
|
||||
output_file: str | None,
|
||||
model: str,
|
||||
max_retries: int = 2,
|
||||
) -> dict:
|
||||
"""Boucle principale : génère, écrit, lint, corrige (max_retries fois)."""
|
||||
file_context = read_files(input_files) if input_files else ""
|
||||
result = {
|
||||
"status": "failure",
|
||||
"files_modified": [],
|
||||
"summary": "",
|
||||
"lint": "skipped",
|
||||
}
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
response = call_lmstudio(task, file_context, model)
|
||||
|
||||
if response.startswith("ERROR:"):
|
||||
result["summary"] = response
|
||||
break
|
||||
|
||||
target = output_file or (input_files[0] if input_files else None)
|
||||
if not target:
|
||||
result["status"] = "success"
|
||||
result["summary"] = response
|
||||
result["lint"] = "skipped (no output file)"
|
||||
break
|
||||
|
||||
if write_file(target, response):
|
||||
result["files_modified"] = [target]
|
||||
|
||||
lint_ok, lint_output = run_lint(target)
|
||||
result["lint"] = "passed" if lint_ok else f"failed: {lint_output[:500]}"
|
||||
|
||||
if lint_ok:
|
||||
result["status"] = "success"
|
||||
result["summary"] = (
|
||||
f"Attempt {attempt + 1}: task completed successfully"
|
||||
)
|
||||
break
|
||||
elif attempt < max_retries:
|
||||
task = f"{task}\n\nCORRECTION REQUISE (tentative {attempt + 1}):\n{lint_output}"
|
||||
file_context = read_files([target])
|
||||
else:
|
||||
result["status"] = "partial"
|
||||
result["summary"] = f"Lint failed after {max_retries + 1} attempts"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Agent loop local pour LM Studio")
|
||||
parser.add_argument("--task", required=True, help="Description de la tâche")
|
||||
parser.add_argument("--files", nargs="*", default=[], help="Fichiers source à lire")
|
||||
parser.add_argument(
|
||||
"--output", help="Fichier de sortie (défaut: premier fichier input)"
|
||||
)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Modèle LM Studio")
|
||||
parser.add_argument(
|
||||
"--list-models", action="store_true", help="Liste les modèles disponibles"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", action="store_true", dest="json_output", help="Sortie JSON"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_models:
|
||||
models = list_models()
|
||||
if models:
|
||||
print("Modèles disponibles :")
|
||||
for m in models:
|
||||
print(f" - {m}")
|
||||
else:
|
||||
print("Aucun modèle trouvé ou LM Studio inaccessible.")
|
||||
return
|
||||
|
||||
result = agent_loop(args.task, args.files, args.output, args.model)
|
||||
|
||||
if args.json_output:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(f"STATUS: {result['status']}")
|
||||
print(f"FILES_MODIFIED: {', '.join(result['files_modified']) or 'none'}")
|
||||
print(f"SUMMARY: {result['summary']}")
|
||||
print(f"LINT: {result['lint']}")
|
||||
|
||||
sys.exit(0 if result["status"] == "success" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
mcp/qwen3-mcp/requirements.txt
Normal file
1
mcp/qwen3-mcp/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests>=2.31.0
|
||||
263
mcp/qwen3-mcp/server.py
Normal file
263
mcp/qwen3-mcp/server.py
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env -S venv/bin/python3
|
||||
"""
|
||||
MCP server stdio exposant un outil `qwen3_task`.
|
||||
Lance aichat en mode serveur au démarrage et gère la boucle tool_calls.
|
||||
Les outils disponibles pour Qwen3 sont chargés depuis llm-functions/functions.json.
|
||||
Appelé par Claude Code via : claude mcp add --transport stdio qwen3 -- .claude/venv/bin/python3 .claude/mcp/qwen3-mcp/server.py
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
QWEN3_MODEL = "LMStudio:qwen/qwen3-coder-next"
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
# Répertoire llm-functions relatif à ce script
|
||||
_HERE = Path(__file__).parent
|
||||
_LLM_FUNCTIONS_DIR = _HERE.parent.parent / "llm-functions"
|
||||
_FUNCTIONS_JSON = _LLM_FUNCTIONS_DIR / "functions.json"
|
||||
_BIN_DIR = _LLM_FUNCTIONS_DIR / "bin"
|
||||
|
||||
|
||||
def _load_qwen3_tools() -> list[dict]:
|
||||
"""Charge les outils depuis llm-functions/functions.json et les wrappe au format OpenAI."""
|
||||
if not _FUNCTIONS_JSON.exists():
|
||||
print(f"WARN: {_FUNCTIONS_JSON} introuvable — aucun outil disponible",
|
||||
file=sys.stderr, flush=True)
|
||||
return []
|
||||
raw = json.loads(_FUNCTIONS_JSON.read_text())
|
||||
return [{"type": "function", "function": tool} for tool in raw]
|
||||
|
||||
|
||||
# Outil MCP exposé à Claude Code
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "qwen3_task",
|
||||
"description": "Délègue une tâche de codage à Qwen3-Coder via aichat. Qwen3 dispose d'outils filesystem, shell et web (llm-functions).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {"type": "string", "description": "Description précise de la tâche"},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Fichiers à mettre en contexte initial (optionnel)",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# --- Gestion du processus aichat ---
|
||||
|
||||
_aichat_proc: subprocess.Popen | None = None
|
||||
_aichat_url: str | None = None
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _start_aichat() -> str:
|
||||
global _aichat_proc, _aichat_url
|
||||
|
||||
if not shutil.which("aichat"):
|
||||
raise RuntimeError("aichat introuvable dans le PATH")
|
||||
|
||||
port = _find_free_port()
|
||||
address = f"127.0.0.1:{port}"
|
||||
|
||||
env = os.environ.copy()
|
||||
if _LLM_FUNCTIONS_DIR.exists():
|
||||
env["AICHAT_FUNCTIONS_DIR"] = str(_LLM_FUNCTIONS_DIR)
|
||||
|
||||
_aichat_proc = subprocess.Popen(
|
||||
["aichat", "--serve", address],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
atexit.register(_stop_aichat)
|
||||
|
||||
url = f"http://{address}"
|
||||
for _ in range(30):
|
||||
try:
|
||||
requests.get(f"{url}/v1/models", timeout=1)
|
||||
_aichat_url = url
|
||||
return url
|
||||
except requests.RequestException:
|
||||
time.sleep(0.3)
|
||||
|
||||
_aichat_proc.kill()
|
||||
raise RuntimeError(f"aichat n'a pas démarré sur {address}")
|
||||
|
||||
|
||||
def _stop_aichat():
|
||||
if _aichat_proc and _aichat_proc.poll() is None:
|
||||
_aichat_proc.terminate()
|
||||
try:
|
||||
_aichat_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_aichat_proc.kill()
|
||||
|
||||
|
||||
# --- Exécution des outils via llm-functions/bin/ ---
|
||||
|
||||
def _execute_tool(name: str, args: dict) -> str:
|
||||
"""Exécute un outil llm-functions via son binaire dans bin/.
|
||||
Les binaires attendent le JSON des arguments comme premier argument positionnel.
|
||||
"""
|
||||
bin_path = _BIN_DIR / name
|
||||
if not bin_path.exists():
|
||||
return f"ERROR: outil '{name}' introuvable dans {_BIN_DIR}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(bin_path), json.dumps(args)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
return output or f"(exit code {result.returncode})"
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"ERROR: timeout lors de l'exécution de '{name}'"
|
||||
except Exception as e:
|
||||
return f"ERROR: {e}"
|
||||
|
||||
|
||||
# --- Boucle tool_calls → Qwen3 ---
|
||||
|
||||
def call_qwen3(task: str, files: list[str]) -> str:
|
||||
if _aichat_url is None:
|
||||
raise RuntimeError("aichat non démarré")
|
||||
|
||||
qwen3_tools = _load_qwen3_tools()
|
||||
|
||||
context = ""
|
||||
for path in files:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
context += f"--- {path} ---\n{f.read()}\n\n"
|
||||
except OSError as e:
|
||||
context += f"--- {path} --- ERREUR: {e}\n\n"
|
||||
|
||||
user_content = f"TÂCHE: {task}"
|
||||
if context:
|
||||
user_content += f"\n\nFICHIERS:\n{context}"
|
||||
|
||||
messages = [{"role": "user", "content": user_content}]
|
||||
payload: dict = {
|
||||
"model": QWEN3_MODEL,
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
if qwen3_tools:
|
||||
payload["tools"] = qwen3_tools
|
||||
|
||||
for _ in range(MAX_TOOL_ITERATIONS):
|
||||
resp = requests.post(
|
||||
f"{_aichat_url}/v1/chat/completions",
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
choice = data["choices"][0]
|
||||
message = choice["message"]
|
||||
payload["messages"].append(message)
|
||||
|
||||
if choice["finish_reason"] != "tool_calls":
|
||||
return message.get("content") or ""
|
||||
|
||||
for tc in message.get("tool_calls", []):
|
||||
fn = tc["function"]
|
||||
args = json.loads(fn["arguments"])
|
||||
result = _execute_tool(fn["name"], args)
|
||||
payload["messages"].append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"content": result,
|
||||
})
|
||||
|
||||
return "ERROR: nombre maximum d'itérations tool_calls atteint"
|
||||
|
||||
|
||||
# --- Protocole MCP stdio ---
|
||||
|
||||
def send(obj: dict):
|
||||
print(json.dumps(obj), flush=True)
|
||||
|
||||
|
||||
def handle(req: dict):
|
||||
method = req.get("method", "")
|
||||
req_id = req.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
send({
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "qwen3-mcp", "version": "3.0.0"},
|
||||
},
|
||||
})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
send({"jsonrpc": "2.0", "id": req_id, "result": {"tools": MCP_TOOLS}})
|
||||
elif method == "tools/call":
|
||||
params = req.get("params", {})
|
||||
name = params.get("name", "")
|
||||
args = params.get("arguments", {})
|
||||
if name == "qwen3_task":
|
||||
try:
|
||||
text = call_qwen3(args["task"], args.get("files", []))
|
||||
send({"jsonrpc": "2.0", "id": req_id, "result": {
|
||||
"content": [{"type": "text", "text": text}]
|
||||
}})
|
||||
except Exception as e:
|
||||
send({"jsonrpc": "2.0", "id": req_id, "result": {
|
||||
"content": [{"type": "text", "text": f"ERROR: {e}"}],
|
||||
"isError": True,
|
||||
}})
|
||||
else:
|
||||
send({"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"Outil inconnu : {name}"}})
|
||||
elif req_id is not None:
|
||||
send({"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"Méthode inconnue : {method}"}})
|
||||
|
||||
|
||||
# --- Démarrage ---
|
||||
|
||||
try:
|
||||
_start_aichat()
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"Impossible de démarrer aichat : {e}"}),
|
||||
file=sys.stderr, flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
handle(json.loads(line))
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr, flush=True)
|
||||
Reference in New Issue
Block a user