Spaces:
Paused
Paused
| """ | |
| CodeAgent v5 — agentní chat na HF Spaces (4× NVIDIA A100 80GB) s lokální | |
| vLLM inference a runtime konfigurací bez restartu. | |
| Novinky oproti v4: | |
| - vLLM běží jako řízený subprocess (`vllm serve`, OpenAI-kompatibilní server) | |
| místo in-process vllm.LLM => nativní tool-calling a reasoning parsery, | |
| žádné ruční parsování <tool_call> tagů. | |
| - NASTAVENÍ ZA BĚHU: tab „Nastavení" v UI + /admin/settings API. Změna modelu, | |
| kvantizace, TP, limitů agenta atd. NEVYŽADUJE restart Space — engine se | |
| přenačte na pozadí, aplikace (UI/API/health) běží nepřetržitě. | |
| - Persistence nastavení: /data/settings.json (storage bucket) nebo .agent/. | |
| - Modely: read-only volume mounty (/repos/<repo_id>) => žádné stahování | |
| velkých vah na 50GB ephemeral disk; fallback download přes hf_transfer. | |
| - Watchdog: pád enginu => automatický restart (max 3×). | |
| - Gradio 6, vLLM 0.25, huggingface_hub 1.x. | |
| Zachováno: nástroje přes lokální runner, sub-agenti (explorer/coder/reviewer), | |
| perzistentní paměť, OpenAI-kompatibilní /v1 API, hybrid fallback na HF router. | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import time | |
| import uuid | |
| from threading import Thread | |
| import gradio as gr | |
| import requests | |
| import uvicorn | |
| from fastapi import FastAPI, HTTPException, Request, status | |
| from fastapi.responses import JSONResponse | |
| from starlette.concurrency import run_in_threadpool | |
| from context import TokenStats, compact_messages, estimate_tokens | |
| from engine import SERVED_MODEL_NAME, VLLMEngine, disk_free_gb | |
| from presets import PRESETS, preset_choices, preset_settings | |
| from settings import ENGINE_FIELDS, SettingsManager | |
| from toolservers import CATALOG, ToolServerManager, search_registry | |
| # ---------------------------------------------------------------- konfigurace | |
| # Secrets zůstávají POUZE v env (HF Space Secrets) — nejsou v runtime settings, | |
| # aby se nedaly vylistovat přes UI/API. | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| AGENT_API_TOKEN = os.environ.get("AGENT_API_TOKEN", "") | |
| GRADIO_AUTH = os.environ.get("GRADIO_AUTH", "") | |
| MEMORY_PATH = ".agent/memory.md" | |
| LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper() | |
| LOG_FILE = os.environ.get("LOG_FILE", "/tmp/codeagent.log") | |
| logging.basicConfig( | |
| level=getattr(logging, LOG_LEVEL, logging.INFO), | |
| format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| handlers=[ | |
| logging.FileHandler(LOG_FILE, encoding="utf-8"), | |
| logging.StreamHandler(), | |
| ], | |
| ) | |
| logger = logging.getLogger("codeagent") | |
| SETTINGS = SettingsManager() | |
| ENGINE = VLLMEngine() | |
| TOKENS = TokenStats() # globální spotřeba od startu aplikace (/health, UI) | |
| TOOL_SERVERS = ToolServerManager() # externí MCP servery (runtime správa) | |
| MAIN_PROMPT = """Jsi CodeAgent — orchestrátor a expertní softwarový inženýr. | |
| Pracuješ v sandboxu workspaces/ na uživatelově počítači. | |
| PRACOVNÍ POSTUP (dodržuj): | |
| 1. PLÁN — u netriviálních úloh nejdřív krátce vypiš plán kroků. | |
| 2. PRŮZKUM — než cokoli změníš, poznej kód: list_files, search_code, | |
| read_file (po částech). U velkých repozitářů deleguj průzkum: | |
| delegate_task(role="explorer", ...). | |
| 3. IMPLEMENTACE — malé ověřitelné kroky. U velkých souborů NIKDY | |
| nepřepisuj celý soubor; použij replace_in_file. Rozsáhlé samostatné | |
| podúlohy deleguj: delegate_task(role="coder", ...). | |
| 4. OVĚŘENÍ — spusť testy/lint/build. Před dokončením nech práci zkontrolovat: | |
| delegate_task(role="reviewer", ...). | |
| PAMĚŤ: | |
| - Na začátku úlohy zvaž recall() — může obsahovat kontext z minulých sezení. | |
| - Důležitá rozhodnutí, konvence projektu a stav rozdělané práce ukládej | |
| přes remember(). Piš stručně a věcně. | |
| BEZPEČNOST: | |
| - Destruktivní operace (rm -rf, push --force, reset --hard) jen po | |
| explicitním souhlasu uživatele. Push jen na vyžádání. | |
| - Odpovídej jazykem uživatele. Na konci shrň změny a jak je ověřit. | |
| """ | |
| SUB_PROMPTS = { | |
| "explorer": "Jsi průzkumný sub-agent (POUZE ČTENÍ). Prozkoumej zadanou část " | |
| "kódu/repozitáře a vrať strukturované shrnutí: architektura, klíčové soubory " | |
| "s cestami, relevantní funkce, závislosti. Buď hutný a konkrétní.", | |
| "coder": "Jsi implementační sub-agent. Proveď PŘESNĚ zadanou podúlohu, ověř ji " | |
| "(test/syntax) a vrať shrnutí: co jsi změnil (soubory + podstata), jak ověřeno, " | |
| "na co si dát pozor. Nedělej nic nad rámec zadání.", | |
| "reviewer": "Jsi review sub-agent (POUZE ČTENÍ). Zkontroluj uvedené změny: " | |
| "korektnost, edge-cases, bezpečnost, styl. Vrať seznam nálezů seřazený podle " | |
| "závažnosti, s cestou a řádkem. Pokud je vše OK, řekni to explicitně.", | |
| } | |
| # ---------------------------------------------------------------- LLM volání | |
| class EngineNotReady(RuntimeError): | |
| pass | |
| def _engine_unready_message() -> str: | |
| st = ENGINE.status_dict() | |
| if st["state"] == "starting": | |
| secs = st.get("loading_seconds") or 0 | |
| return (f"Model `{st['model']}` se načítá ({secs}s). " | |
| f"Velké modely mohou startovat i několik minut — zkus to za chvíli.") | |
| if st["state"] == "error": | |
| return f"Engine je v chybovém stavu: {st.get('last_error') or 'neznámá chyba'}" | |
| return "Inference engine není spuštěný (viz tab Nastavení)." | |
| _response_cache: dict = {} | |
| def _cache_get(key: str, ttl: int): | |
| entry = _response_cache.get(key) | |
| if not entry or ttl <= 0: | |
| return None | |
| if time.time() - entry["ts"] > ttl: | |
| _response_cache.pop(key, None) | |
| return None | |
| return entry["value"] | |
| def _cache_key(messages, tools) -> str: | |
| import hashlib | |
| data = json.dumps({ | |
| "rev": SETTINGS.revision, | |
| "model": ENGINE.current_model, | |
| "messages": messages, | |
| "tools": [t.get("function", {}).get("name") for t in (tools or [])], | |
| }, ensure_ascii=False, sort_keys=True) | |
| return hashlib.sha256(data.encode("utf-8")).hexdigest()[:32] | |
| def _kimi_api_call(messages, tools): | |
| from openai import OpenAI | |
| s = SETTINGS.get() | |
| client = OpenAI(base_url="https://router.huggingface.co/v1", api_key=HF_TOKEN) | |
| kwargs = dict(model=s.kimi_model, messages=messages, | |
| max_tokens=s.max_output_tokens, temperature=s.temperature) | |
| if tools: | |
| kwargs["tools"] = tools | |
| kwargs["tool_choice"] = "auto" | |
| return client.chat.completions.create(**kwargs) | |
| def _local_call(messages, tools): | |
| if not ENGINE.is_ready: | |
| raise EngineNotReady(_engine_unready_message()) | |
| s = SETTINGS.get() | |
| client = ENGINE.openai_client() | |
| kwargs = dict(model=SERVED_MODEL_NAME, messages=messages, | |
| max_tokens=s.max_output_tokens, temperature=s.temperature) | |
| if tools: | |
| kwargs["tools"] = tools | |
| kwargs["tool_choice"] = "auto" | |
| return client.chat.completions.create(**kwargs) | |
| def _llm(messages, tools, prefer_api=False, use_cache=True, stats=None): | |
| """Jedno LLM volání. `stats` = per-turn TokenStats (globální TOKENS se | |
| plní vždy). Cache hity se do spotřeby nepočítají — nic nestály.""" | |
| s = SETTINGS.get() | |
| key = _cache_key(messages, tools) | |
| if use_cache: | |
| cached = _cache_get(key, s.cache_ttl_seconds) | |
| if cached is not None: | |
| logger.info("Cache hit %s", key) | |
| return cached | |
| logger.info("LLM call mode=%s prefer_api=%s", s.agent_mode, prefer_api) | |
| if s.agent_mode == "hybrid" and prefer_api: | |
| source = "api" | |
| resp = _kimi_api_call(messages, tools) | |
| else: | |
| source = "local" | |
| resp = _local_call(messages, tools) | |
| usage = getattr(resp, "usage", None) | |
| TOKENS.add(source, usage) | |
| if stats is not None: | |
| stats.add(source, usage) | |
| if use_cache and s.cache_ttl_seconds > 0: | |
| _response_cache[key] = {"ts": time.time(), "value": resp} | |
| return resp | |
| # ---------------------------------------------------------------- hybrid routing | |
| # | |
| # V hybrid módu rozhoduje o každé úloze router: jednoduché úlohy řeší rychlý | |
| # lokální model, složité (architektura, deep debugging, security, plánování) | |
| # jdou na velký API model (kimi_model přes HF router). | |
| # | |
| # router_mode = "ai": složitost klasifikuje SÁM lokální model (jedno levné | |
| # volání, ~8 tokenů, temperature 0). Při chybě/nejednoznačnosti se spadne | |
| # na statická keywords pravidla. router_mode = "keywords": jen pravidla. | |
| ROUTER_PROMPT = """You are a complexity router for a coding agent. Decide which \ | |
| engine should handle the user's request: | |
| LOCAL — fast local coding model. Choose for: routine implementation, reading or \ | |
| editing files, writing tests, small bug fixes, running commands, mechanical \ | |
| refactors, straightforward questions about code. | |
| API — much larger remote model (slow, expensive). Choose ONLY for: system or \ | |
| architecture design, deep debugging / root-cause analysis across components, \ | |
| security audits, complex trade-off analysis, planning large multi-module \ | |
| refactors, ambiguous tasks that require heavy reasoning. | |
| Answer with exactly one word: LOCAL or API.""" | |
| API_KEYWORDS = [ | |
| "architektura", "architecture", "design", "root cause", "proč se to děje", | |
| "zranitelnost", "security", "best practice", "trade-off", | |
| "porovnej", "komplexní analýza", "deep debugging", | |
| ] | |
| def _router_llm_call(messages) -> str: | |
| """Jedno levné volání lokálního modelu (oddělené kvůli testům).""" | |
| client = ENGINE.openai_client() | |
| resp = client.chat.completions.create( | |
| model=SERVED_MODEL_NAME, messages=messages, | |
| max_tokens=8, temperature=0.0) | |
| TOKENS.add("router", getattr(resp, "usage", None)) | |
| return resp.choices[0].message.content or "" | |
| def _ai_route(prompt: str) -> str | None: | |
| """AI klasifikace složitosti. None = nelze rozhodnout (použij fallback).""" | |
| if not ENGINE.is_ready: | |
| return None | |
| try: | |
| raw = _router_llm_call([ | |
| {"role": "system", "content": ROUTER_PROMPT}, | |
| {"role": "user", "content": prompt[:4000]}, | |
| ]).strip().upper() | |
| except Exception as e: | |
| logger.warning("AI router selhal (%s) — fallback na keywords", e) | |
| return None | |
| api_pos = raw.find("API") | |
| local_pos = raw.find("LOCAL") | |
| if api_pos >= 0 and (local_pos < 0 or api_pos < local_pos): | |
| return "api" | |
| if local_pos >= 0: | |
| return "local" | |
| logger.warning("AI router: nejednoznačná odpověď %r — fallback", raw[:60]) | |
| return None | |
| def _keyword_route(prompt: str) -> str: | |
| p = prompt.lower() | |
| return "api" if any(k in p for k in API_KEYWORDS) else "local" | |
| def _route_task(prompt: str) -> str: | |
| """Rozhodne local vs api. Pořadí: explicitní tagy > délka kontextu > | |
| AI router (router_mode=ai) > keywords.""" | |
| s = SETTINGS.get() | |
| p = prompt.lower() | |
| if any(tag in p for tag in ("[kimi]", "@kimi", "[api]", "@api")): | |
| return "api" | |
| if "[qwen]" in p or "@qwen" in p or "[local]" in p: | |
| return "local" | |
| if len(prompt) > s.local_context_limit: | |
| return "api" | |
| if s.router_mode == "ai": | |
| decision = _ai_route(prompt) | |
| if decision: | |
| logger.info("AI router → %s (%s...)", decision, prompt[:60]) | |
| return decision | |
| return _keyword_route(prompt) | |
| # ---------------------------------------------------------------- runner klient | |
| def _runner_post(path: str, method: str = "post", params: dict = None, | |
| payload: dict = None) -> dict: | |
| s = SETTINGS.get() | |
| if not s.runner_url: | |
| return {"error": "runner_url není nastaveno (tab Nastavení nebo LOCAL_RUNNER_URL)."} | |
| url = f"{s.runner_url.rstrip('/')}{path}" | |
| kwargs = {"timeout": s.runner_timeout} | |
| if s.runner_token: | |
| kwargs["headers"] = {"Authorization": f"Bearer {s.runner_token}"} | |
| try: | |
| if method.lower() == "get": | |
| r = requests.get(url, params=params, **kwargs) | |
| else: | |
| r = requests.post(url, json=payload, **kwargs) | |
| if r.status_code != 200: | |
| return {"error": f"Runner HTTP {r.status_code}: {r.text[:500]}"} | |
| return r.json() | |
| except requests.RequestException as e: | |
| return {"error": f"Spojení s runnerem selhalo: {e}"} | |
| def run_command(command: str, cwd: str = "") -> dict: | |
| return _runner_post("/execute", payload={"command": command, "cwd": cwd}) | |
| def git_command(args: str, cwd: str = "") -> dict: | |
| return _runner_post("/execute", payload={"command": f"git {args}", "cwd": cwd}) | |
| def read_file(path: str, start_line: int = 1, end_line: int = 0) -> dict: | |
| return _runner_post("/files/read", method="get", | |
| params={"path": path, "start_line": start_line, "end_line": end_line}) | |
| def write_file(path: str, content: str) -> dict: | |
| return _runner_post("/files/write", payload={"path": path, "content": content}) | |
| def replace_in_file(path: str, old: str, new: str) -> dict: | |
| return _runner_post("/files/replace", payload={"path": path, "old": old, "new": new}) | |
| def search_code(pattern: str, path: str = ".", glob: str = "") -> dict: | |
| res = _runner_post("/files/grep", method="get", params={ | |
| "query": pattern, "path": path, "regex": "true", | |
| "match_per_line": "true", "max_results": "50"}) | |
| if "error" in res: | |
| return res | |
| return {"matches": res} | |
| def list_files(path: str = ".") -> dict: | |
| return _runner_post("/files/list", method="get", params={"directory": path}) | |
| def remember(note: str) -> dict: | |
| stamp = time.strftime("%Y-%m-%d %H:%M") | |
| cur = _runner_post("/files/read", method="get", params={"path": MEMORY_PATH}) | |
| content = cur.get("content", "") if "error" not in cur else "# CodeAgent memory\n" | |
| content += f"\n- [{stamp}] {note.strip()}" | |
| return _runner_post("/files/write", payload={"path": MEMORY_PATH, "content": content}) | |
| def recall() -> dict: | |
| res = _runner_post("/files/read", method="get", params={"path": MEMORY_PATH}) | |
| if "error" in res: | |
| return {"memory": "(pamet je zatim prazdna)"} | |
| return {"memory": res.get("content", "")[-8000:]} | |
| # ---------------------------------------------------------------- nástroje | |
| def _tool(name, desc, props, required=None): | |
| return { | |
| "type": "function", | |
| "function": { | |
| "name": name, | |
| "description": desc, | |
| "parameters": {"type": "object", "properties": props, "required": required or []}, | |
| }, | |
| } | |
| S = {"type": "string"} | |
| I = {"type": "integer"} | |
| BASE_TOOLS = [ | |
| _tool("run_command", "Spusti shell prikaz v sandboxu workspaces/.", | |
| {"command": S, "cwd": S}, ["command"]), | |
| _tool("git_command", "Spusti git podprikaz (bez slova git), napr. 'status'.", | |
| {"args": S, "cwd": S}, ["args"]), | |
| _tool("read_file", | |
| "Precte soubor. U velkych souboru VZDY pouzij start_line/end_line " | |
| "(napr. 1-200) misto cteni celeho souboru.", | |
| {"path": S, "start_line": I, "end_line": I}, ["path"]), | |
| _tool("write_file", "Zapise NOVY soubor (u existujicich preferuj replace_in_file).", | |
| {"path": S, "content": S}, ["path", "content"]), | |
| _tool("replace_in_file", | |
| "Nahradi presny textovy usek v souboru (old musi byt v souboru prave " | |
| "jednou; pridej okolni radky pro jednoznacnost). Preferovany zpusob edits.", | |
| {"path": S, "old": S, "new": S}, ["path", "old", "new"]), | |
| _tool("search_code", | |
| "Fulltext/regex hledani v kodu. Vraci soubor:radek:text. Pouzivej pro " | |
| "orientaci ve velkych repozitarich misto cteni vseho.", | |
| {"pattern": S, "path": S, "glob": {"type": "string", "description": "napr. *.py"}}, | |
| ["pattern"]), | |
| _tool("list_files", "Vypise adresar ve workspaces/.", {"path": S}), | |
| ] | |
| READONLY = {"run_command", "git_command", "read_file", "search_code", "list_files"} | |
| MEMORY_TOOLS = [ | |
| _tool("remember", "Ulozi trvalou poznamku do pameti (rozhodnuti, konvence, stav prace).", | |
| {"note": S}, ["note"]), | |
| _tool("recall", "Nacte obsah trvale pameti.", {}), | |
| ] | |
| DELEGATE_TOOL = _tool( | |
| "delegate_task", | |
| "Deleguje podulohu na sub-agenta s cistym kontextem. role: 'explorer' " | |
| "(pruzkum, jen cteni), 'coder' (implementace), 'reviewer' (kontrola, jen cteni). " | |
| "Zadej samostatne uzavrenou ulohu vc. potrebneho kontextu a cest.", | |
| {"role": {"type": "string", "enum": ["explorer", "coder", "reviewer"]}, | |
| "task": S, "cwd": S}, | |
| ["role", "task"], | |
| ) | |
| TOOL_FUNCS = { | |
| "run_command": run_command, "git_command": git_command, "read_file": read_file, | |
| "write_file": write_file, "replace_in_file": replace_in_file, | |
| "search_code": search_code, "list_files": list_files, | |
| "remember": remember, "recall": recall, | |
| } | |
| def _exec_tool(name, args, allowed_names, stats=None): | |
| if name == "delegate_task": | |
| return run_subagent(args.get("role", "explorer"), | |
| args.get("task", ""), args.get("cwd", ""), | |
| stats=stats) | |
| if name.startswith("ext_"): | |
| # Externí MCP nástroje má jen hlavní agent ("external" v allowed). | |
| if "external" not in allowed_names: | |
| return {"error": "Externí nástroje nejsou v této roli povoleny."} | |
| return TOOL_SERVERS.call(name, args) | |
| if name not in allowed_names: | |
| return {"error": f"Nastroj {name} neni v teto roli povolen."} | |
| func = TOOL_FUNCS.get(name) | |
| if not func: | |
| return {"error": f"Neznamy nastroj {name}"} | |
| try: | |
| return func(**args) | |
| except TypeError as e: | |
| return {"error": f"Spatne argumenty: {e}"} | |
| # ---------------------------------------------------------------- agent loop | |
| def _context_budget(s) -> int: | |
| """Token budget kontextu: explicitní, nebo auto z max_model_len.""" | |
| if s.context_budget_tokens > 0: | |
| return s.context_budget_tokens | |
| return max(4096, s.max_model_len - s.max_output_tokens - 2048) | |
| def agent_loop(system_prompt, user_messages, tools, allowed_names, | |
| max_steps, yield_progress=False, prefer_api=False, stats=None): | |
| """Hlavní smyčka. Yielduje ("tool", text) | ("note", text) | ("final", text). | |
| `stats` (TokenStats) sbírá spotřebu tokenů celého běhu vč. sub-agentů. | |
| Před každým voláním se kontext hlídá proti budgetu a případně kompaktuje. | |
| """ | |
| messages = [{"role": "system", "content": system_prompt}] + user_messages | |
| api_failover = False | |
| for _ in range(max_steps): | |
| s = SETTINGS.get() | |
| mode = s.agent_mode | |
| if s.context_compaction == "trim": | |
| messages, saved = compact_messages( | |
| messages, _context_budget(s), | |
| keep_last_steps=s.context_keep_last_steps, | |
| aged_chars=s.tool_result_aged_chars) | |
| if saved > 0: | |
| TOKENS.add_saved(saved) | |
| if stats is not None: | |
| stats.add_saved(saved) | |
| if yield_progress: | |
| yield ("note", f"🧹 Kontext zhutněn (~{saved} tokenů ušetřeno, " | |
| f"nyní ~{estimate_tokens(messages)} tok)") | |
| try: | |
| resp = _llm(messages, tools, prefer_api=prefer_api or api_failover, | |
| stats=stats) | |
| except EngineNotReady as e: | |
| if mode == "hybrid" and HF_TOKEN: | |
| if yield_progress: | |
| yield ("tool", f"⚠️ Lokální engine není připraven, přepínám na API: {e}") | |
| api_failover = True | |
| try: | |
| resp = _llm(messages, tools, prefer_api=True, stats=stats) | |
| except Exception as e2: | |
| yield ("final", f"❌ Chyba i u API fallbacku: {e2}") | |
| return | |
| else: | |
| yield ("final", f"⏳ {e}") | |
| return | |
| except Exception as e: | |
| if mode == "hybrid" and not (prefer_api or api_failover): | |
| if yield_progress: | |
| yield ("tool", f"⚠️ Lokální model selhal, přepínám na API: {e}") | |
| api_failover = True | |
| try: | |
| resp = _llm(messages, tools, prefer_api=True, stats=stats) | |
| except Exception as e2: | |
| yield ("final", f"❌ Chyba i u API fallbacku: {e2}") | |
| return | |
| else: | |
| yield ("final", f"❌ Chyba LLM: {e}") | |
| return | |
| msg = resp.choices[0].message | |
| tool_calls = msg.tool_calls or [] | |
| if not tool_calls: | |
| yield ("final", msg.content or "(prazdna odpoved)") | |
| return | |
| tcs = [{"id": tc.id, "type": "function", | |
| "function": {"name": tc.function.name, "arguments": tc.function.arguments}} | |
| for tc in tool_calls] | |
| messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": tcs}) | |
| for tc in tool_calls: | |
| try: | |
| args = json.loads(tc.function.arguments or "{}") | |
| except json.JSONDecodeError: | |
| args = {} | |
| result = _exec_tool(tc.function.name, args, allowed_names, stats=stats) | |
| if yield_progress: | |
| a = json.dumps(args, ensure_ascii=False)[:200] | |
| r = json.dumps(result, ensure_ascii=False)[:600] | |
| yield ("tool", f"🔧 **{tc.function.name}** `{a}`\n```json\n{r}\n```") | |
| messages.append({"role": "tool", "tool_call_id": tc.id, | |
| "content": json.dumps(result, ensure_ascii=False) | |
| [:s.tool_result_max_chars]}) | |
| yield ("final", f"⚠️ Limit {max_steps} kroku dosazen.") | |
| SUBAGENT_ROLES = ("explorer", "coder", "reviewer") | |
| def subagent_config(role: str, s=None) -> dict | None: | |
| """Runtime konfigurace sub-agenta: enabled, step limit, prompt (override | |
| z nastavení, jinak výchozí). None = neznámá role.""" | |
| if role not in SUBAGENT_ROLES: | |
| return None | |
| s = s or SETTINGS.get() | |
| return { | |
| "enabled": getattr(s, f"subagent_{role}_enabled"), | |
| "max_steps": getattr(s, f"max_{role}_steps"), | |
| "prompt": getattr(s, f"subagent_{role}_prompt").strip() or SUB_PROMPTS[role], | |
| } | |
| def enabled_subagent_roles(s=None) -> list[str]: | |
| s = s or SETTINGS.get() | |
| return [r for r in SUBAGENT_ROLES if getattr(s, f"subagent_{r}_enabled")] | |
| def run_subagent(role: str, task: str, cwd: str = "", stats=None) -> dict: | |
| cfg = subagent_config(role) | |
| if cfg is None: | |
| return {"error": f"Neznama role {role}"} | |
| if not cfg["enabled"]: | |
| return {"error": f"Sub-agent '{role}' je vypnuty v nastaveni — " | |
| f"proved ulohu sam pomoci zakladnich nastroju."} | |
| allowed = READONLY if role in ("explorer", "reviewer") else set(TOOL_FUNCS) - {"remember", "recall"} | |
| tools = [t for t in BASE_TOOLS if t["function"]["name"] in allowed] | |
| user = [{"role": "user", "content": (f"Pracovni adresar: {cwd}\n\n" if cwd else "") + task}] | |
| final = "(bez vysledku)" | |
| steps = 0 | |
| for kind, data in agent_loop(cfg["prompt"], user, tools, allowed, | |
| cfg["max_steps"], yield_progress=True, | |
| stats=stats): | |
| if kind == "tool": | |
| steps += 1 | |
| elif kind == "final": | |
| final = data | |
| return {"role": role, "steps": steps, "result": final} | |
| def _history_text(content) -> str: | |
| """Gradio 6 history: content je str NEBO list content-bloků.""" | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| parts = [] | |
| for block in content: | |
| if isinstance(block, dict) and block.get("type") == "text": | |
| parts.append(block.get("text", "")) | |
| elif isinstance(block, str): | |
| parts.append(block) | |
| return "\n".join(p for p in parts if p) | |
| return "" | |
| def build_main_messages(history, message): | |
| mem = recall().get("memory", "") | |
| msgs = [] | |
| if mem and "prazdna" not in mem: | |
| msgs.append({"role": "system", "content": f"TRVALA PAMET:\n{mem}"}) | |
| for t in history or []: | |
| if t.get("role") in ("user", "assistant"): | |
| text = _history_text(t.get("content")) | |
| if text: | |
| msgs.append({"role": t["role"], "content": text}) | |
| msgs.append({"role": "user", "content": message}) | |
| return msgs | |
| MAIN_ALLOWED = set(TOOL_FUNCS) | {"external"} | |
| def build_main_tools(s=None) -> list[dict]: | |
| """Nástroje hlavního agenta dle aktuální konfigurace: delegate_task | |
| nabízí jen zapnuté role (bez rolí se vynechá) + externí MCP nástroje.""" | |
| roles = enabled_subagent_roles(s) | |
| tools = BASE_TOOLS + MEMORY_TOOLS | |
| if roles: | |
| delegate = json.loads(json.dumps(DELEGATE_TOOL)) # hluboká kopie | |
| delegate["function"]["parameters"]["properties"]["role"]["enum"] = roles | |
| tools = tools + [delegate] | |
| return tools + TOOL_SERVERS.openai_tools() | |
| # ---------------------------------------------------------------- Gradio: chat | |
| def _compact_tool_line(entry: str) -> str: | |
| """Z plného tool záznamu nechá jen první řádek (jméno + argumenty).""" | |
| return entry.split("\n", 1)[0] | |
| def _stats_footer(stats: TokenStats) -> str: | |
| snap = stats.snapshot() | |
| if snap["calls"] == 0: | |
| return "" | |
| fmt = lambda n: f"{n:,}".replace(",", " ") # noqa: E731 | |
| parts = [f"📊 {snap['calls']}× LLM", | |
| f"vstup {fmt(snap['prompt_tokens'])} tok", | |
| f"výstup {fmt(snap['completion_tokens'])} tok", | |
| f"kontext {fmt(snap['last_context_tokens'])} tok"] | |
| if snap["saved_by_compaction_tokens"]: | |
| parts.append(f"🧹 kompakce ušetřila ~{fmt(snap['saved_by_compaction_tokens'])} tok") | |
| return "*" + " · ".join(parts) + "*" | |
| def agent_chat(message, history): | |
| logger.info("agent_chat request: %s...", str(message)[:120]) | |
| s = SETTINGS.get() | |
| if not s.runner_url: | |
| yield ("❌ Chybí runner_url — nastav ho v tabu Nastavení " | |
| "(nebo env LOCAL_RUNNER_URL).") | |
| return | |
| verbosity = s.chat_verbosity | |
| use_api = False | |
| clean_msg = message | |
| if s.agent_mode == "hybrid": | |
| use_api = _route_task(message) == "api" | |
| clean_msg = re.sub(r"\[(kimi|qwen|api|local)\]|@(kimi|qwen|api)", "", | |
| message, flags=re.IGNORECASE).strip() | |
| stats = TokenStats() | |
| log = [] | |
| steps = 0 | |
| final_text = "(prazdna odpoved)" | |
| msgs = build_main_messages(history or [], clean_msg or message) | |
| for kind, data in agent_loop(MAIN_PROMPT, msgs, build_main_tools(s), | |
| MAIN_ALLOWED, s.max_steps, | |
| yield_progress=True, prefer_api=use_api, | |
| stats=stats): | |
| if kind in ("tool", "note"): | |
| if kind == "tool": | |
| steps += 1 | |
| if verbosity == "full": | |
| log.append(data) | |
| yield "\n\n".join(log + ["⏳ pracuji..."]) | |
| elif verbosity == "compact": | |
| log.append(_compact_tool_line(data)) | |
| yield "\n".join(log) + "\n\n⏳ pracuji..." | |
| else: # final — jen nenápadný heartbeat | |
| label = data.split("**")[1] if data.count("**") >= 2 else "" | |
| yield f"⏳ pracuji… (krok {steps}{': ' + label if label else ''})" | |
| elif kind == "final": | |
| final_text = data | |
| parts = [] | |
| if log: | |
| parts.append("\n\n".join(log) if verbosity == "full" else "\n".join(log)) | |
| parts.append(final_text) | |
| footer = _stats_footer(stats) if verbosity != "final" else "" | |
| if footer: | |
| parts.append("---\n" + footer) | |
| yield "\n\n".join(parts) | |
| # ---------------------------------------------------------------- Gradio: nastavení | |
| # (name, label, typ komponenty) — pořadí = pořadí vstupů apply handleru | |
| ENGINE_FORM = [ | |
| ("model", "Model (HF repo id / cesta / /repos mount)", "text"), | |
| ("model_revision", "Revize modelu (commit/tag, prázdné = latest)", "text"), | |
| ("download_dir", "Download adresář vah (prázdné = auto; bucket např. /data/models)", "text"), | |
| ("tensor_parallel_size", "Tensor parallel (počet GPU)", "int"), | |
| ("gpu_memory_utilization", "GPU memory utilization", "float"), | |
| ("max_model_len", "Max délka kontextu", "int"), | |
| ("quantization", "Kvantizace", ("dropdown", ["auto", "none", "fp8", "awq", "awq_marlin", "gptq_marlin", "bitsandbytes"])), | |
| ("dtype", "Dtype", ("dropdown", ["auto", "bfloat16", "float16", "float32"])), | |
| ("kv_cache_dtype", "KV cache dtype", ("dropdown", ["auto", "fp8", "fp8_e5m2", "fp8_e4m3"])), | |
| ("enforce_eager", "Enforce eager (vypnout CUDA graphs)", "bool"), | |
| ("enable_prefix_caching", "Prefix caching", "bool"), | |
| ("max_num_seqs", "Max souběžných sekvencí (0 = default)", "int"), | |
| ("tool_call_parser", "Tool-call parser (auto = podle modelu)", "text"), | |
| ("reasoning_parser", "Reasoning parser (auto = podle modelu)", "text"), | |
| ("engine_extra_args", "Extra vLLM argumenty", "text"), | |
| ] | |
| AGENT_FORM = [ | |
| ("agent_mode", "Režim", ("dropdown", ["single", "hybrid"])), | |
| ("router_mode", "Hybrid routing (ai = klasifikuje lokální model)", | |
| ("dropdown", ["ai", "keywords"])), | |
| ("kimi_model", "API model (hybrid fallback)", "text"), | |
| ("local_context_limit", "Limit kontextu pro lokální model (hybrid)", "int"), | |
| ("temperature", "Temperature", "float"), | |
| ("max_output_tokens", "Max output tokenů", "int"), | |
| ("max_steps", "Max kroků hlavního agenta", "int"), | |
| ("chat_verbosity", "Výřečnost chatu (full/compact/final)", | |
| ("dropdown", ["full", "compact", "final"])), | |
| ("runner_url", "Runner URL", "text"), | |
| ("runner_token", "Runner token", "password"), | |
| ("runner_timeout", "Runner timeout (s)", "int"), | |
| ("cache_ttl_seconds", "Cache TTL (s, 0 = vypnuto)", "int"), | |
| ("log_level", "Log level", ("dropdown", ["DEBUG", "INFO", "WARNING", "ERROR"])), | |
| ] | |
| # Per-role konfigurace sub-agentů — v UI vykresleno jako 3 sloupce. | |
| SUBAGENT_FORM = [ | |
| ("subagent_explorer_enabled", "Explorer zapnut", "bool"), | |
| ("max_explorer_steps", "Explorer: max kroků", "int"), | |
| ("subagent_explorer_prompt", "Explorer: vlastní prompt (prázdné = výchozí)", "multiline"), | |
| ("subagent_coder_enabled", "Coder zapnut", "bool"), | |
| ("max_coder_steps", "Coder: max kroků", "int"), | |
| ("subagent_coder_prompt", "Coder: vlastní prompt (prázdné = výchozí)", "multiline"), | |
| ("subagent_reviewer_enabled", "Reviewer zapnut", "bool"), | |
| ("max_reviewer_steps", "Reviewer: max kroků", "int"), | |
| ("subagent_reviewer_prompt", "Reviewer: vlastní prompt (prázdné = výchozí)", "multiline"), | |
| ] | |
| CONTEXT_FORM = [ | |
| ("context_compaction", "Kompakce kontextu", ("dropdown", ["trim", "off"])), | |
| ("context_budget_tokens", "Token budget kontextu (0 = auto z max_model_len)", "int"), | |
| ("context_keep_last_steps", "Posledních N bloků vždy v plném znění", "int"), | |
| ("tool_result_max_chars", "Max znaků výsledku nástroje", "int"), | |
| ("tool_result_aged_chars", "Znaků po zestárnutí výsledku", "int"), | |
| ] | |
| ALL_FORM = ENGINE_FORM + AGENT_FORM + SUBAGENT_FORM + CONTEXT_FORM | |
| FORM_FIELD_NAMES = [f[0] for f in ALL_FORM] | |
| def _build_component(name, label, kind): | |
| value = SETTINGS.as_dict().get(name) | |
| if kind == "int" or kind == "float": | |
| return gr.Number(label=label, value=value, precision=None if kind == "float" else 0) | |
| if kind == "bool": | |
| return gr.Checkbox(label=label, value=bool(value)) | |
| if kind == "password": | |
| return gr.Textbox(label=label, value=str(value or ""), type="password") | |
| if kind == "multiline": | |
| return gr.Textbox(label=label, value=str(value or ""), lines=3) | |
| if isinstance(kind, tuple) and kind[0] == "dropdown": | |
| choices = kind[1] | |
| if value not in choices and value not in (None, ""): | |
| choices = [value] + choices | |
| return gr.Dropdown(label=label, choices=choices, value=value) | |
| return gr.Textbox(label=label, value=str(value or "")) | |
| def engine_status_markdown() -> str: | |
| st = ENGINE.status_dict() | |
| icons = {"ready": "🟢", "starting": "🟡", "error": "🔴", "stopped": "⚪"} | |
| icon = icons.get(st["state"], "⚪") | |
| lines = [f"### {icon} Engine: `{st['state']}`"] | |
| if st.get("model"): | |
| src = st.get("model_source") or "?" | |
| lines.append(f"**Model:** `{st['model']}` (zdroj: {src})") | |
| if st.get("tensor_parallel_clamped_from"): | |
| lines.append(f"⚠️ **TP sníženo {st['tensor_parallel_clamped_from']} → " | |
| f"{st['effective_tensor_parallel']}** — detekováno jen " | |
| f"{st['detected_gpus']} GPU. Zkontroluj hardware Space " | |
| f"(`space_ctl.py hardware`).") | |
| if st.get("download_dir"): | |
| free = st.get("download_dir_free_gb") | |
| free_txt = f" · volno {free} GB" if free is not None else "" | |
| lines.append(f"**Download dir:** `{st['download_dir']}`{free_txt}") | |
| if st.get("loading_seconds"): | |
| lines.append(f"**Načítání:** {st['loading_seconds']} s") | |
| if st.get("uptime_seconds"): | |
| lines.append(f"**Uptime:** {st['uptime_seconds']} s") | |
| if st.get("last_error"): | |
| err = st["last_error"][:600] | |
| lines.append(f"**Chyba:**\n```\n{err}\n```") | |
| lines.append(f"**Disk volný:** {disk_free_gb('/')} GB · " | |
| f"**Nastavení rev:** {SETTINGS.revision}") | |
| snap = TOKENS.snapshot() | |
| if snap["calls"]: | |
| fmt = lambda n: f"{n:,}".replace(",", " ") # noqa: E731 | |
| saved = (f" · 🧹 ušetřeno ~{fmt(snap['saved_by_compaction_tokens'])}" | |
| if snap["saved_by_compaction_tokens"] else "") | |
| lines.append(f"**Tokeny (od startu):** {snap['calls']}× LLM · " | |
| f"vstup {fmt(snap['prompt_tokens'])} · " | |
| f"výstup {fmt(snap['completion_tokens'])} · " | |
| f"poslední kontext {fmt(snap['last_context_tokens'])}{saved}") | |
| return "\n\n".join(lines) | |
| def _apply_settings(*values): | |
| changes = dict(zip(FORM_FIELD_NAMES, values)) | |
| # Number komponenty vrací float — int pole zpět na int | |
| applied, engine_reload, errors = SETTINGS.update(changes) | |
| parts = [] | |
| if applied: | |
| shown = {k: ("***" if k == "runner_token" else v) for k, v in applied.items()} | |
| parts.append(f"✅ Uloženo: `{json.dumps(shown, ensure_ascii=False)}`") | |
| else: | |
| parts.append("ℹ️ Žádná změna.") | |
| if errors: | |
| parts.append(f"❌ Chyby: `{json.dumps(errors, ensure_ascii=False)}`") | |
| if engine_reload: | |
| s = SETTINGS.get() | |
| ENGINE.remember_settings(s) | |
| ENGINE.reload(s) | |
| parts.append("🔄 Změny vyžadují reload enginu — **probíhá na pozadí**, " | |
| "Space se nerestartuje. Sleduj stav výše.") | |
| elif applied: | |
| parts.append("⚡ Změny platí okamžitě (bez reloadu).") | |
| return "\n\n".join(parts), engine_status_markdown() | |
| def _apply_preset(preset_key): | |
| if not preset_key: | |
| return "ℹ️ Vyber preset.", *[gr.update() for _ in ALL_FORM] | |
| changes = preset_settings(preset_key) | |
| info = PRESETS[preset_key] | |
| updates = [] | |
| current = SETTINGS.as_dict() | |
| for name in FORM_FIELD_NAMES: | |
| if name in changes: | |
| updates.append(gr.update(value=changes[name])) | |
| else: | |
| updates.append(gr.update(value=current.get(name))) | |
| note = (f"📦 Preset **{info['label']}** předvyplněn " | |
| f"(~{info['weights_gb']} GB vah). {info['notes']}\n\n" | |
| f"Ulož tlačítkem **Uložit a aplikovat**.") | |
| return note, *updates | |
| def _engine_reload_now(): | |
| s = SETTINGS.get() | |
| ENGINE.remember_settings(s) | |
| ENGINE.reload(s) | |
| return "🔄 Reload enginu spuštěn na pozadí.", engine_status_markdown() | |
| def _engine_stop(): | |
| ENGINE.stop() | |
| return "⏹️ Engine zastaven.", engine_status_markdown() | |
| def _refresh_form(): | |
| current = SETTINGS.as_dict() | |
| return [gr.update(value=current.get(name)) for name in FORM_FIELD_NAMES] | |
| # ---------------------------------------------------------------- Gradio: externí nástroje | |
| def _toolservers_status_md() -> str: | |
| rows = TOOL_SERVERS.list() | |
| if not rows: | |
| return ("Žádné externí servery. Přidej server z katalogu níže, nebo " | |
| "vyhledej v oficiálním registru.") | |
| lines = ["| Server | Stav | Nástroje | Zapnut |", "|---|---|---|---|"] | |
| for r in rows: | |
| icon = {"ok": "🟢", "error": "🔴"}.get(r["status"], "⚪") | |
| detail = f" — `{r['error'][:80]}`" if r.get("error") else "" | |
| names = ", ".join(r["tool_names"][:8]) | |
| if len(r["tool_names"]) > 8: | |
| names += ", …" | |
| lines.append(f"| **{r['name']}**<br>`{r['url']}` | {icon} {r['status']}{detail} " | |
| f"| {r['tools']}: {names} | {'✅' if r['enabled'] else '⛔'} |") | |
| return "\n".join(lines) | |
| def _catalog_md() -> str: | |
| lines = ["**Ověřené veřejné servery** (připoj jedním kliknutím — vyplní formulář):"] | |
| for s in CATALOG["servers"]: | |
| lines.append(f"- `{s['url']}` — **{s['name']}**: {s['desc']}") | |
| lines.append("\n**Registry a adresáře pro hledání dalších:**") | |
| for r in CATALOG["registries"]: | |
| lines.append(f"- [{r['name']}]({r['url']}) — {r['desc']}") | |
| lines.append("\n⚠️ Výstupy externích nástrojů jsou nedůvěryhodná data — " | |
| "připojuj jen servery, kterým věříš.") | |
| return "\n".join(lines) | |
| def _ts_add(name, url, token, allowed_tools, enabled, timeout): | |
| try: | |
| TOOL_SERVERS.add_or_update(name=name, url=url, token=token or "", | |
| enabled=bool(enabled), | |
| allowed_tools=allowed_tools or "", | |
| timeout=int(timeout or 60)) | |
| server = [s for s in TOOL_SERVERS.list() if s["name"] == name.strip().lower().replace("-", "_")] \ | |
| or TOOL_SERVERS.list() | |
| st = server[0]["status"] if server else "?" | |
| msg = f"✅ Server uložen a obnoven (stav: {st}). Nástroje jsou agentovi dostupné okamžitě." | |
| except Exception as e: # noqa: BLE001 — chybu ukazujeme v UI | |
| msg = f"❌ {e}" | |
| return msg, _toolservers_status_md() | |
| def _ts_remove(name): | |
| ok = TOOL_SERVERS.remove(name.strip().lower().replace("-", "_")) | |
| return ("🗑️ Server odebrán." if ok else "ℹ️ Server nenalezen."), _toolservers_status_md() | |
| def _ts_refresh_all(): | |
| results = TOOL_SERVERS.refresh() | |
| return f"🔄 Obnoveno: `{json.dumps(results, ensure_ascii=False)}`", _toolservers_status_md() | |
| def _ts_registry_search(query): | |
| query = (query or "").strip() | |
| if not query: | |
| return "ℹ️ Zadej hledaný výraz." | |
| try: | |
| results = search_registry(query, limit=10) | |
| except Exception as e: # noqa: BLE001 | |
| return f"❌ Registr nedostupný: {e}" | |
| if not results: | |
| return f"Nic nenalezeno pro `{query}` (jen servery s HTTP endpointem)." | |
| lines = [f"Nalezeno v [oficiálním registru](https://registry.modelcontextprotocol.io) " | |
| f"(jen remote/HTTP):"] | |
| for r in results: | |
| lines.append(f"- **{r['name']}** — {r['description']}\n" | |
| f" - URL: {' · '.join('`' + u + '`' for u in r['urls'])}") | |
| lines.append("\nZkopíruj URL do formuláře výše a ulož.") | |
| return "\n".join(lines) | |
| # ---------------------------------------------------------------- Gradio UI | |
| with gr.Blocks(title="CodeAgent v5") as demo: | |
| gr.Markdown("# 🛠️ CodeAgent v5 — lokální vLLM na 4×A100, nastavení za běhu") | |
| with gr.Tabs(): | |
| with gr.Tab("💬 Chat"): | |
| status_bar = gr.Markdown(value=engine_status_markdown) | |
| gr.ChatInterface( | |
| fn=agent_chat, | |
| api_name="chat", | |
| examples=[ | |
| "Prozkoumej repo my-project (deleguj na explorera) a shrň architekturu.", | |
| "[kimi] Navrhni architekturu datové pipeline pro náš nový projekt.", | |
| "Zapamatuj si: v projektu my-project používáme pytest a black.", | |
| "Najdi v my-project všechna použití funkce parse_config a refaktoruj ji.", | |
| "Co si pamatuješ z minulých sezení?", | |
| ], | |
| ) | |
| with gr.Tab("⚙️ Nastavení"): | |
| gr.Markdown( | |
| "Změny se aplikují **za běhu** — Space se nerestartuje. " | |
| "Pole enginu (model, kvantizace, …) vyvolají reload vLLM na pozadí; " | |
| "ostatní platí okamžitě. Nastavení se persistuje do " | |
| "`/data/settings.json` (bucket) nebo `.agent/settings.json`." | |
| ) | |
| settings_status = gr.Markdown(value=engine_status_markdown) | |
| result_md = gr.Markdown() | |
| with gr.Row(): | |
| preset_dd = gr.Dropdown(label="📦 Preset pro 4×A100 (320 GB VRAM)", | |
| choices=preset_choices(), value=None) | |
| preset_btn = gr.Button("Předvyplnit preset") | |
| components = {} | |
| with gr.Accordion("🧠 Inference engine (reload za běhu)", open=True): | |
| with gr.Row(): | |
| with gr.Column(): | |
| for name, label, kind in ENGINE_FORM[:7]: | |
| components[name] = _build_component(name, label, kind) | |
| with gr.Column(): | |
| for name, label, kind in ENGINE_FORM[7:]: | |
| components[name] = _build_component(name, label, kind) | |
| with gr.Accordion("🤖 Agent, runner a ostatní (platí okamžitě)", open=False): | |
| with gr.Row(): | |
| with gr.Column(): | |
| for name, label, kind in AGENT_FORM[:7]: | |
| components[name] = _build_component(name, label, kind) | |
| with gr.Column(): | |
| for name, label, kind in AGENT_FORM[7:]: | |
| components[name] = _build_component(name, label, kind) | |
| with gr.Accordion("🤝 Sub-agenti (platí okamžitě)", open=False): | |
| gr.Markdown("Vypnutá role zmizí z nabídky `delegate_task`; " | |
| "vlastní prompt nahradí výchozí systémový prompt role.") | |
| with gr.Row(): | |
| for start in (0, 3, 6): # sloupec na roli | |
| with gr.Column(): | |
| for name, label, kind in SUBAGENT_FORM[start:start + 3]: | |
| components[name] = _build_component(name, label, kind) | |
| with gr.Accordion("🧮 Kontext a tokeny (platí okamžitě)", open=False): | |
| gr.Markdown("Kompakce drží kontext pod budgetem: staré výsledky " | |
| "nástrojů se zkrátí, nejstarší kroky se vypustí se " | |
| "souhrnnou poznámkou. Systémový prompt, zadání a " | |
| "posledních N bloků zůstává vždy celé.") | |
| with gr.Row(): | |
| with gr.Column(): | |
| for name, label, kind in CONTEXT_FORM[:3]: | |
| components[name] = _build_component(name, label, kind) | |
| with gr.Column(): | |
| for name, label, kind in CONTEXT_FORM[3:]: | |
| components[name] = _build_component(name, label, kind) | |
| form_inputs = [components[n] for n in FORM_FIELD_NAMES] | |
| with gr.Row(): | |
| apply_btn = gr.Button("💾 Uložit a aplikovat", variant="primary") | |
| reload_btn = gr.Button("🔄 Reload enginu") | |
| stop_btn = gr.Button("⏹️ Stop engine") | |
| refresh_btn = gr.Button("↩️ Načíst uložené hodnoty") | |
| # Při každém načtení stránky naplnit formulář AKTUÁLNÍMI hodnotami | |
| # (jinak by po změně přes API/jiný prohlížeč viselo staré nastavení | |
| # a „Uložit" by ho nechtěně vrátilo zpět). | |
| demo.load(_refresh_form, outputs=form_inputs) | |
| apply_btn.click(_apply_settings, inputs=form_inputs, | |
| outputs=[result_md, settings_status]) | |
| preset_btn.click(_apply_preset, inputs=[preset_dd], | |
| outputs=[result_md] + form_inputs) | |
| reload_btn.click(_engine_reload_now, outputs=[result_md, settings_status]) | |
| stop_btn.click(_engine_stop, outputs=[result_md, settings_status]) | |
| refresh_btn.click(_refresh_form, outputs=form_inputs) | |
| status_timer = gr.Timer(5) | |
| status_timer.tick(lambda: (engine_status_markdown(), engine_status_markdown()), | |
| outputs=[settings_status, status_bar]) | |
| with gr.Tab("🧰 Externí nástroje"): | |
| gr.Markdown( | |
| "Připoj **MCP servery** (Model Context Protocol, Streamable HTTP) " | |
| "— jejich nástroje dostane hlavní agent okamžitě, bez restartu. " | |
| "Správa i volání probíhají za běhu; token se ukládá maskovaně." | |
| ) | |
| ts_status = gr.Markdown(value=_toolservers_status_md) | |
| ts_result = gr.Markdown() | |
| with gr.Row(): | |
| with gr.Column(): | |
| ts_name = gr.Textbox(label="Jméno (krátké, bez mezer)", value="") | |
| ts_url = gr.Textbox(label="URL (…/mcp endpoint)", value="") | |
| ts_token = gr.Textbox(label="Bearer token (volitelné)", | |
| type="password", value="") | |
| with gr.Column(): | |
| ts_allowed = gr.Textbox( | |
| label="Povolené nástroje (čárkami; prázdné = všechny)", value="") | |
| ts_enabled = gr.Checkbox(label="Zapnut", value=True) | |
| ts_timeout = gr.Number(label="Timeout (s)", value=60, precision=0) | |
| with gr.Row(): | |
| ts_add_btn = gr.Button("💾 Přidat / aktualizovat", variant="primary") | |
| ts_remove_btn = gr.Button("🗑️ Odebrat (dle jména)") | |
| ts_refresh_btn = gr.Button("🔄 Obnovit nástroje všech") | |
| with gr.Accordion("🔎 Hledat v oficiálním MCP registru", open=False): | |
| with gr.Row(): | |
| ts_query = gr.Textbox(label="Hledat servery (např. github, postgres, browser)") | |
| ts_search_btn = gr.Button("Hledat") | |
| ts_search_out = gr.Markdown() | |
| with gr.Accordion("📚 Katalog ověřených zdrojů", open=False): | |
| gr.Markdown(value=_catalog_md) | |
| ts_add_btn.click(_ts_add, | |
| inputs=[ts_name, ts_url, ts_token, ts_allowed, | |
| ts_enabled, ts_timeout], | |
| outputs=[ts_result, ts_status]) | |
| ts_remove_btn.click(_ts_remove, inputs=[ts_name], | |
| outputs=[ts_result, ts_status]) | |
| ts_refresh_btn.click(_ts_refresh_all, outputs=[ts_result, ts_status]) | |
| ts_search_btn.click(_ts_registry_search, inputs=[ts_query], | |
| outputs=[ts_search_out]) | |
| with gr.Tab("📜 Logy"): | |
| log_box = gr.Textbox(label="vLLM engine log (tail)", lines=24, | |
| value=lambda: ENGINE.log_tail(80)) | |
| app_log_box = gr.Textbox(label="Aplikační log (tail)", lines=12) | |
| log_refresh = gr.Button("Obnovit") | |
| def _read_app_log(): | |
| try: | |
| with open(LOG_FILE, encoding="utf-8", errors="replace") as f: | |
| return "".join(f.readlines()[-60:]) | |
| except OSError: | |
| return "(log nedostupný)" | |
| log_refresh.click(lambda: (ENGINE.log_tail(80), _read_app_log()), | |
| outputs=[log_box, app_log_box]) | |
| # ---------------------------------------------------------------- FastAPI | |
| api = FastAPI(title="CodeAgent API", docs_url=None, redoc_url=None, openapi_url=None) | |
| def _cuda_info() -> dict: | |
| try: | |
| import torch | |
| return {"available": torch.cuda.is_available(), | |
| "devices": torch.cuda.device_count() if torch.cuda.is_available() else 0} | |
| except Exception: | |
| return {"available": False, "devices": 0} | |
| async def health(): | |
| s = SETTINGS.get() | |
| return JSONResponse(status_code=status.HTTP_200_OK, content={ | |
| "status": "ok", | |
| "mode": s.agent_mode, | |
| "model": s.model, | |
| "kimi_model": s.kimi_model if s.agent_mode == "hybrid" else None, | |
| "engine": ENGINE.status_dict(), | |
| "runner_configured": bool(s.runner_url), | |
| "api_token_configured": bool(AGENT_API_TOKEN), | |
| "cuda": _cuda_info(), | |
| "disk_free_gb": disk_free_gb("/"), | |
| "settings_revision": SETTINGS.revision, | |
| "cache_entries": len(_response_cache), | |
| "tokens": TOKENS.snapshot(), | |
| "tool_servers": TOOL_SERVERS.summary(), | |
| }) | |
| def _check_api_auth(request: Request): | |
| if not AGENT_API_TOKEN: | |
| raise HTTPException(503, "AGENT_API_TOKEN neni nastaven.") | |
| if request.headers.get("Authorization", "") != f"Bearer {AGENT_API_TOKEN}": | |
| raise HTTPException(401, "Neplatny API token.") | |
| # ---- admin: runtime nastavení bez restartu ---- | |
| async def admin_get_settings(request: Request): | |
| _check_api_auth(request) | |
| return {"settings": SETTINGS.as_dict(), "revision": SETTINGS.revision, | |
| "engine_fields": sorted(ENGINE_FIELDS)} | |
| async def admin_post_settings(request: Request, reload: bool = True): | |
| _check_api_auth(request) | |
| body = await request.json() | |
| if not isinstance(body, dict): | |
| raise HTTPException(400, "Očekávám JSON objekt {pole: hodnota}.") | |
| applied, engine_reload, errors = SETTINGS.update(body) | |
| reload_started = False | |
| if engine_reload and reload: | |
| s = SETTINGS.get() | |
| ENGINE.remember_settings(s) | |
| await run_in_threadpool(ENGINE.reload, s) | |
| reload_started = True | |
| return {"applied": {k: ("***" if k == "runner_token" else v) for k, v in applied.items()}, | |
| "errors": errors, | |
| "engine_reload_needed": engine_reload, | |
| "engine_reload_started": reload_started, | |
| "revision": SETTINGS.revision} | |
| async def admin_engine_status(request: Request): | |
| _check_api_auth(request) | |
| return ENGINE.status_dict() | |
| async def admin_engine_reload(request: Request): | |
| _check_api_auth(request) | |
| s = SETTINGS.get() | |
| ENGINE.remember_settings(s) | |
| await run_in_threadpool(ENGINE.reload, s) | |
| return {"status": "reload_started", "model": s.model} | |
| async def admin_engine_stop(request: Request): | |
| _check_api_auth(request) | |
| await run_in_threadpool(ENGINE.stop) | |
| return {"status": "stopped"} | |
| async def admin_logs(request: Request, source: str = "engine", lines: int = 80): | |
| _check_api_auth(request) | |
| lines = max(1, min(lines, 500)) | |
| if source == "engine": | |
| return {"source": "engine", "log": ENGINE.log_tail(lines)} | |
| try: | |
| with open(LOG_FILE, encoding="utf-8", errors="replace") as f: | |
| content = "".join(f.readlines()[-lines:]) | |
| except OSError as e: | |
| content = f"(chyba: {e})" | |
| return {"source": "app", "log": content} | |
| async def admin_presets(request: Request): | |
| _check_api_auth(request) | |
| return {"presets": PRESETS} | |
| # ---- externí tool servery (MCP) ---- | |
| async def admin_toolservers_list(request: Request): | |
| _check_api_auth(request) | |
| return {"servers": TOOL_SERVERS.list(), "summary": TOOL_SERVERS.summary()} | |
| async def admin_toolservers_add(request: Request): | |
| _check_api_auth(request) | |
| body = await request.json() | |
| try: | |
| await run_in_threadpool( | |
| TOOL_SERVERS.add_or_update, | |
| body.get("name", ""), body.get("url", ""), | |
| body.get("token", ""), bool(body.get("enabled", True)), | |
| body.get("allowed_tools", ""), int(body.get("timeout", 60))) | |
| except (ValueError, TypeError) as e: | |
| raise HTTPException(400, str(e)) | |
| return {"servers": TOOL_SERVERS.list(), "summary": TOOL_SERVERS.summary()} | |
| async def admin_toolservers_delete(request: Request): | |
| _check_api_auth(request) | |
| body = await request.json() | |
| removed = TOOL_SERVERS.remove(body.get("name", "")) | |
| if not removed: | |
| raise HTTPException(404, "Server nenalezen") | |
| return {"servers": TOOL_SERVERS.list()} | |
| async def admin_toolservers_refresh(request: Request): | |
| _check_api_auth(request) | |
| body = {} | |
| try: | |
| body = await request.json() | |
| except Exception: # noqa: BLE001 — prázdné tělo = obnovit vše | |
| pass | |
| results = await run_in_threadpool(TOOL_SERVERS.refresh, body.get("name")) | |
| return {"results": results, "servers": TOOL_SERVERS.list()} | |
| async def admin_toolservers_registry(request: Request, q: str, limit: int = 10): | |
| _check_api_auth(request) | |
| try: | |
| results = await run_in_threadpool(search_registry, q, min(limit, 30)) | |
| except Exception as e: # noqa: BLE001 | |
| raise HTTPException(502, f"Registr nedostupný: {e}") | |
| return {"query": q, "results": results} | |
| async def admin_toolservers_catalog(request: Request): | |
| _check_api_auth(request) | |
| return CATALOG | |
| # ---- OpenAI-kompatibilní API ---- | |
| async def models(request: Request): | |
| _check_api_auth(request) | |
| now = int(time.time()) | |
| return {"object": "list", "data": [ | |
| {"id": "code-agent", "object": "model", "created": now, "owned_by": "codeagent"}, | |
| {"id": SERVED_MODEL_NAME, "object": "model", "created": now, "owned_by": "codeagent"}, | |
| ]} | |
| async def chat_completions(request: Request): | |
| _check_api_auth(request) | |
| body = await request.json() | |
| requested_model = body.get("model", "code-agent") | |
| raw = body.get("messages", []) | |
| logger.info("API /v1/chat/completions model=%s", requested_model) | |
| # Passthrough: přímý přístup k lokálnímu modelu bez agentní smyčky. | |
| if requested_model in (SERVED_MODEL_NAME, "raw", "vllm"): | |
| if not ENGINE.is_ready: | |
| raise HTTPException(503, _engine_unready_message()) | |
| s = SETTINGS.get() | |
| client = ENGINE.openai_client() | |
| kwargs = dict(model=SERVED_MODEL_NAME, messages=raw, | |
| max_tokens=body.get("max_tokens", s.max_output_tokens), | |
| temperature=body.get("temperature", s.temperature)) | |
| if body.get("tools"): | |
| kwargs["tools"] = body["tools"] | |
| kwargs["tool_choice"] = body.get("tool_choice", "auto") | |
| resp = await run_in_threadpool(lambda: client.chat.completions.create(**kwargs)) | |
| TOKENS.add("passthrough", getattr(resp, "usage", None)) | |
| return json.loads(resp.model_dump_json()) | |
| history = [{"role": m["role"], "content": m["content"]} | |
| for m in raw[:-1] | |
| if m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str)] | |
| last = raw[-1]["content"] if raw else "" | |
| if isinstance(last, list): | |
| last = " ".join(b.get("text", "") for b in last if isinstance(b, dict)) | |
| s = SETTINGS.get() | |
| use_api = s.agent_mode == "hybrid" and _route_task(last) == "api" | |
| msgs = build_main_messages(history, last) | |
| stats = TokenStats() | |
| def _run_agent(): | |
| log, final = [], "" | |
| for kind, data in agent_loop(MAIN_PROMPT, msgs, build_main_tools(s), | |
| MAIN_ALLOWED, s.max_steps, | |
| yield_progress=True, prefer_api=use_api, | |
| stats=stats): | |
| if kind in ("tool", "note"): | |
| log.append(data) | |
| elif kind == "final": | |
| final = data | |
| return log, final | |
| # Blokující inference nesmí zamrazit event loop (jinak neodpovídá /health). | |
| log, final = await run_in_threadpool(_run_agent) | |
| if s.chat_verbosity == "full" and log: | |
| content = "\n\n".join(log) + "\n\n---\n\n" + final | |
| elif s.chat_verbosity == "compact" and log: | |
| content = "\n".join(_compact_tool_line(x) for x in log) + "\n\n---\n\n" + final | |
| else: | |
| content = final | |
| snap = stats.snapshot() | |
| return { | |
| "id": f"chatcmpl-{uuid.uuid4().hex[:12]}", | |
| "object": "chat.completion", | |
| "created": int(time.time()), | |
| "model": "code-agent", | |
| "choices": [{"index": 0, "finish_reason": "stop", | |
| "message": {"role": "assistant", "content": content}}], | |
| "usage": {"prompt_tokens": snap["prompt_tokens"], | |
| "completion_tokens": snap["completion_tokens"], | |
| "total_tokens": snap["total_tokens"]}, | |
| } | |
| app = gr.mount_gradio_app( | |
| api, demo, path="/", | |
| auth=tuple(GRADIO_AUTH.split(":", 1)) if GRADIO_AUTH else None, | |
| theme=gr.themes.Soft(), | |
| ssr_mode=False, | |
| ) | |
| # ---------------------------------------------------------------- start enginu | |
| PRELOAD_MODEL = os.environ.get("PRELOAD_MODEL", "1").lower() in ("1", "true", "yes") | |
| def _preload(): | |
| import shutil as _shutil | |
| binary = os.environ.get("VLLM_BINARY", "vllm") | |
| if _shutil.which(binary) is None: | |
| logger.warning("vLLM binárka '%s' nenalezena — engine nespuštěn " | |
| "(vývojový režim bez GPU).", binary) | |
| return | |
| s = SETTINGS.get() | |
| ENGINE.remember_settings(s) | |
| ENGINE.start(s) | |
| if PRELOAD_MODEL: | |
| Thread(target=_preload, daemon=True, name="engine-preload").start() | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860"))) | |