CoolFace
Apppublic

build-small-hackathon/MiniCPM5-1B-Agent

sourceHugging Faceupdated 3mo agoView on Hugging Face
3likes
app.py1107 linesDownload Raw Back to root
1"""MiniCPM5-1B-Agent - agentic coding demo Space.2 3Wires the SAME backend agent loop used by the eval (backend/agent.py: think -> tool-call ->4run -> read output -> debug -> verify) around the shipped Q8_0 GGUF, served by llama-server.5The user gives a coding task; the agent writes/runs/fixes code in a sandbox and we render the6full write->run->verify trajectory + final answer + produced files INLINE in the chat thread.7 8Runtime layout (Docker /app): app.py · backend/agent.py · data/schema.py · tokenizer/ · model.gguf9Env (set by Dockerfile, overridable for local test):10  CODEAGENT_PROJ      -> dir holding data/schema.py        (default: this file's dir)11  CODEAGENT_LLAMA_BIN -> llama-server binary               (default: "llama-server")12  CODEAGENT_GGUF      -> path to the Q8_0 GGUF             (default: /app/model.gguf)13  CODEAGENT_CTX       -> llama-server context (default 8192)14  CODEAGENT_THREADS   -> llama-server threads (default 2 - free 2-vCPU tier)15"""16import os, sys, glob, html, atexit, shutil, tempfile, time, threading17 18HERE = os.path.dirname(os.path.abspath(__file__))19# agent.py reads these; set BEFORE importing it so PROJ/LLAMA_BIN resolve to the Space layout.20os.environ.setdefault("CODEAGENT_PROJ", HERE)               # -> data/schema.py is at HERE/data21os.environ.setdefault("CODEAGENT_LLAMA_BIN", "llama-server")22sys.path.insert(0, HERE)                                    # for `import backend.agent`23sys.path.insert(0, os.path.join(HERE, "backend"))           # agent.py also imported flat24 25import gradio as gr26from transformers import AutoTokenizer27import backend.agent as agent28 29GGUF = os.environ.get("CODEAGENT_GGUF", "/app/model.gguf")30CTX = int(os.environ.get("CODEAGENT_CTX", "8192"))31THREADS = int(os.environ.get("CODEAGENT_THREADS", "2"))32MAX_ITERS = int(os.environ.get("CODEAGENT_MAX_ITERS", "8"))33# PER-TURN ACTION budget (phase B). The runaway <think> is bounded SEPARATELY (CODEAGENT_THINK_CAP, default34# 1024), so this only sizes the model's ACTION (a tool call / a file write / the final answer). Balance: at35# ~8 tok/s on the free 2-vCPU tier, a full-budget action costs ~budget/8 seconds, so an over-large cap makes36# a verbose turn take 20+ min. 6144 fits a clean, compact file write (the prompt now tells the model to WRITE37# the file, not paste it) while keeping turns reasonable; and since the Chatbot no longer strips iframe srcdoc38# (sanitize_html=False), a page that DOES exceed the cap now renders PARTIALLY via the salvage path instead of39# as a blank box - so truncation degrades gracefully rather than failing hard. Override via CODEAGENT_NPRED.40NPRED = int(os.environ.get("CODEAGENT_NPRED", "6144"))41 42# Private deployment: if the GGUF isn't already on disk, pull it from the (PRIVATE) model repo using the43# HF_TOKEN Space secret. (Local/public runs instead point CODEAGENT_GGUF at an existing file.)44if not os.path.exists(GGUF) and os.environ.get("MODEL_REPO"):45    from huggingface_hub import hf_hub_download46    _repo, _fn = os.environ["MODEL_REPO"], os.environ["MODEL_FILE"]47    print(f"[app] downloading {_repo}/{_fn} (private; HF_TOKEN={'set' if os.environ.get('HF_TOKEN') else 'MISSING'}) ...", flush=True)48    GGUF = hf_hub_download(repo_id=_repo, filename=_fn, token=os.environ.get("HF_TOKEN"), local_dir="/app")49 50# The tokenizer lives WITH the model (in the GGUF repo under tokenizer/) - one source of truth, never bundled51# in the Space. Pull it from MODEL_REPO with the same HF_TOKEN secret used for the GGUF.52_tokdir = os.path.join(HERE, "tokenizer")53if not os.path.exists(os.path.join(_tokdir, "tokenizer.json")) and os.environ.get("MODEL_REPO"):54    from huggingface_hub import snapshot_download55    print(f"[app] downloading tokenizer from {os.environ['MODEL_REPO']}/tokenizer ...", flush=True)56    snapshot_download(repo_id=os.environ["MODEL_REPO"], allow_patterns="tokenizer/*",57                      token=os.environ.get("HF_TOKEN"), local_dir=HERE)58print(f"[app] loading tokenizer from {_tokdir} ...", flush=True)59TOK = AutoTokenizer.from_pretrained(_tokdir, trust_remote_code=True)60 61# SECURITY: the GGUF + tokenizer are now downloaded, so HF_TOKEN is no longer needed. DROP it from the62# process env BEFORE any per-turn Sandbox is created - the bash tool runs model-emitted shell with the full63# inherited env, so leaving the private-repo secret in os.environ would let a task `echo $HF_TOKEN` exfil it.64os.environ.pop("HF_TOKEN", None)65os.environ.pop("HUGGING_FACE_HUB_TOKEN", None)66 67print(f"[app] starting llama-server on {GGUF} (ctx={CTX}, threads={THREADS}, ngl=0) ...", flush=True)68SERVER = agent.LlamaServer(GGUF, port=8099, ctx=CTX, threads=THREADS, ngl=0)69SERVER.__enter__()                       # block until /health ok; kept alive for the app lifetime70atexit.register(lambda: SERVER.__exit__(None, None, None))71print("[app] llama-server healthy - ready.", flush=True)72 73# Web tools: AUTO-DETECT at startup (no manual flag). If the internet is reachable, turn on web_search/web_fetch.74WEB_ON = agent.web_available()75if WEB_ON:76    agent.enable_web()77    print("[app] internet reachable -> web tools ON (web_search + web_fetch incl. Reddit via arctic_shift)", flush=True)78else:79    print("[app] no internet -> web tools OFF (fully local / Off-the-Grid)", flush=True)80 81# Best-effort: make /workspace exist + writable. The 1B (Claude-Code habit) sometimes savefig's/writes to an82# ABSOLUTE '/workspace/...' path INSIDE its script - the bash-path rewrite can't reach in-code paths, so without83# this the write would fail (or land at an unscanned root) and the artifact would never render. With /workspace84# present, those writes succeed and _extra_media() scans it (this-turn-only). Harmless if perms deny it (the85# model then uses relative paths in the per-session sandbox, which _media_messages already scans).86try:87    os.makedirs("/workspace", exist_ok=True)88except Exception:89    pass90 91# Every example is a NATURAL request (how a real user actually asks - no backend hand-holding; the92# save/run/verify conventions live in the SYSTEM PROMPT, not here) and was VALIDATED on the shipped dpo_v393# GGUF through THIS agent loop. The first two PASS on the fine-tune but FAIL on the un-tuned base, so they94# showcase what the training added. The 1B handles a chart, an HTML mini-app, and a computed answer, but ONLY95# when the prompt is concrete (name the .html file / "hard-coded, no internet"); an open-ended "make a web96# page" reliably gets mis-read as a Python CLI or triggers a pointless web_search. (Charts/GIFs/games need97# step-by-step spoon-feeding a 1B can't infer from a natural prompt - left out.)98# Each example exercises a DISTINCT capability so clicking through them shows the range: a matplotlib99# chart -> PNG, an interactive HTML mini-app, and a compute + web look-up.100EXAMPLES = [101    # python chart -> image shown inline in the chat (phrased as "Python script ... saves a PNG ... run it" so the 1B102    # writes a .py + runs it, instead of dumping code into the .png; full ctx + the save-plots system rule help)103    "Write a Python script that makes a bar chart of the values 30, 45, 25 labeled A, B, C, saves it as chart.png, then run it.",104    # interactive HTML mini-app -> renders live inline. EXPLICIT phrasing ("Write an HTML page <file>.html ...105    # hard-coded ... no internet") so the 1B writes a single self-contained .html FILE instead of mis-reading106    # "web page" as a Python CLI or web_search'ing (the open-ended "make a little web page" prompt reliably107    # botched both). Mirrors the write-a-file pattern the 1B nails.108    "Write an HTML page quote.html with a button that shows a different random quote on each click. Use a few hard-coded quotes in JavaScript - no internet needed.",109    # practical compute + WEB look-up (when online): "look up this year's avg rent" implicitly needs web_search110    "How many $40 video games can I buy in a year if I make $2000 a month and pay rent? Look up this year's average rent in the USA, then work it out for me.",111]112 113 114import re as _re115# Display-only cruft: control tokens AND leaked tool-call XML. When the 1B emits a malformed/partial tool call,116# raw <function>/<param>/CDATA fragments can spill into the rendered bubble (e.g. "...]]></param></function>");117# strip those tags from the SHOWN text only. This does NOT touch the agent loop's real XML parsing in agent.py.118_CRUFT = _re.compile(119    r"</?think>|<\|im_(start|end)\|>|<\|endoftext\|>"120    r"|</?function[^>]*>|</?param[^>]*>|<!\[CDATA\[|\]\]>")121 122 123def _clean(t):124    """Display-only: strip special/control tokens AND leaked tool-call XML that can spill into rendered text125    (no effect on the agent loop's parsing)."""126    return _CRUFT.sub("", t or "").strip()127 128 129def _fmt_tool_call(c):130    # Tool calls are stored in the canonical NESTED shape {"type":"function","function":{name,arguments}}131    # (agent.py run_agent). Read the nested function.name/function.arguments directly; tolerate a flat dict.132    fn = c.get("function") or c133    name = fn.get("name", "?")134    arguments = fn.get("arguments") or {}135    arg_preview = ", ".join(f"{k}={str(v)[:60]!r}" for k, v in list(arguments.items())[:3])136    return f"🔧 **`{name}`**({arg_preview})"137 138 139def _tool_disclosure(body, max_chars=1500):140    """One collapsible <details> block for a tool's (possibly long) output - de-dupes the two identical141    blocks the renderer used for role:tool and the legacy <tool_response> user message."""142    return f"<details><summary>📤 tool output</summary>\n\n```\n{str(body)[:max_chars]}\n```\n</details>"143 144 145def _assistant_parts(m):146    """Read an assistant turn's display parts from the CANONICAL stored fields (reasoning_content,147    tool_calls, content) rather than re-parsing content. On a tool-calling turn the loop already split148    <think>/tool-calls out and set content="", so re-parsing would recover nothing. Returns149    (reasoning, tool_calls, final)."""150    return (_clean(m.get("reasoning_content") or ""),151            m.get("tool_calls") or [],152            _clean(m.get("content") or ""))153 154 155def _render(messages, max_chars=1500):156    """Render the write->run->verify trajectory as readable markdown (assistant turns only - the user's157    task is already shown as their own chat bubble). Renders the CANONICAL message fields the agent loop158    stored (reasoning_content, tool_calls, content) directly - it does NOT re-parse content, because on a159    tool-calling turn the loop already split <think>/tool-calls out and set content="", so re-parsing would160    recover nothing and the turn would render blank."""161    out = []162    for m in messages:163        role = m.get("role")164        content = m.get("content") or ""165        if role in ("system", "user"):166            if role == "user" and content.startswith("<tool_response>"):167                body = content.replace("<tool_response>", "").replace("</tool_response>", "").strip()168                out.append(_tool_disclosure(body, max_chars))169            continue170        if role == "tool":171            out.append(_tool_disclosure(content, max_chars))172            continue173        # assistant: render the canonical fields the loop stored (reasoning_content, tool_calls, content).174        reasoning, tool_calls, final = _assistant_parts(m)175        if reasoning:176            out.append(f"<details><summary>💭 thinking</summary>\n\n{html.escape(reasoning[:max_chars])}\n</details>")177        for c in tool_calls:178            out.append(_fmt_tool_call(c))179        if final:180            # The final answer renders as PLAIN chat-bubble text (it is already in the assistant bubble) - no181            # "🤖"-narration prefix or bold framing that made it read like internal monologue / blur with the182            # user's own turn. Reasoning + tool output stay in their collapsible <details> blocks above.183            out.append(html.escape(final[:max_chars]))184    return "\n\n".join(out)185 186 187IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp")188SVG_EXT = (".svg",)   # rendered inline as HTML (gr.Image rasterizers don't reliably handle SVG)189VID_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")190 191 192def _copy_out(p, outdir):193    """Copy an artifact OUT of the (ephemeral, per-session) workspace into a stable temp dir Gradio can194    serve, and return the destination path."""195    dst = os.path.join(outdir, os.path.basename(p))196    shutil.copy2(p, dst)197    return dst198 199 200def _media_bubble(content):201    """Wrap a media component (gr.Image/gr.Video/gr.HTML) as one assistant chat message so it renders202    INLINE in the thread (ChatGPT-style), not in a separate box."""203    return {"role": "assistant", "content": content}204 205 206def _html_iframe(body):207    """A live, SANDBOXED iframe for an agent-produced .html page (opaque origin: no allow-same-origin, so208    the page's JS/forms/buttons run but stay contained), wrapped in a tiny browser-chrome "live preview"209    frame so the running mini-app reads as a real artifact, not a floating white rectangle. srcdoc is210    html.escape'd (quote=True default) so a " in the page can't break out of the attribute. Sizing/border211    come from CSS (.lp-frame / .lp-frame iframe)."""212    iframe = (f'<iframe sandbox="allow-scripts" referrerpolicy="no-referrer" '213              f'srcdoc="{html.escape(body)}"></iframe>')214    return (f'<div class="lp-frame">'215            f'<div class="lp-bar"><span class="lp-dot"></span>'216            f'<span class="lp-label">live preview</span></div>{iframe}</div>')217 218 219_HTML_START = _re.compile(r"<!DOCTYPE html|<html[\s>]", _re.IGNORECASE)220 221 222def _salvage_html(text):223    """The 1B often PASTES a full HTML page into its prose answer instead of write()-ing a file (so no .html224    file exists to iframe). Extract the document so the demo still renders it LIVE - and tolerate a page that225    got CUT OFF mid-tag (no </html>): render what we have (browsers are lenient) and flag it so the chat can226    say WHY it looks broken. Returns (html, warning) - warning is None when the page is complete."""227    if not isinstance(text, str):228        return None, None229    m = _HTML_START.search(text)230    if not m:231        return None, None232    doc = text[m.start():]233    end = doc.lower().rfind("</html>")234    if end != -1:                                   # complete page: trim trailing prose after </html>235        return doc[:end + 7].strip(), None236    return doc.strip(), ("the model pasted the page into its reply instead of saving it to a file, then hit the "237                         "length limit and got cut off (no `</html>`). Rendering the partial page; ask it to "238                         "*save the page to an .html file with the write tool* so the whole thing renders.")239 240 241def _valid_image(p):242    """True only if p starts with a real raster-image magic number. Guards against the 1B 'writing' a chart.png243    as TEXT (a bogus file that would render as an empty/0x0 bubble) - such a file fails this check and is skipped."""244    try:245        with open(p, "rb") as f:246            head = f.read(12)247    except OSError:248        return False249    return (head.startswith(b"\x89PNG") or head.startswith(b"\xff\xd8\xff") or head[:4] == b"GIF8"250            or head[:2] == b"BM" or (head[:4] == b"RIFF" and head[8:12] == b"WEBP"))251 252 253def _render_media_file(p, outdir):254    """Map ONE produced file to an inline chat bubble by extension, or None if it's not media. png/jpg/gif/... ->255    gr.Image; .svg / .html -> a sandboxed iframe (SVG is active content, so never inline it raw into the parent256    DOM); video -> gr.Video. Shared by the sandbox scan AND the extra-dir scan."""257    ext = os.path.splitext(p)[1].lower()258    try:259        if ext in IMG_EXT:260            return _media_bubble(gr.Image(_copy_out(p, outdir))) if _valid_image(p) else None261        if ext in VID_EXT:262            return _media_bubble(gr.Video(_copy_out(p, outdir)))263        if ext in SVG_EXT or ext in (".html", ".htm"):264            return _media_bubble(gr.HTML(_html_iframe(open(p, encoding="utf-8", errors="replace").read())))265    except Exception:266        return None267    return None268 269 270def _extra_media(dirs, since_ts, exclude_dir):271    """Catch media the model wrote to an ABSOLUTE path OUTSIDE its per-session sandbox - e.g. a savefig to272    '/workspace/chart.png' INSIDE the script (a Claude-Code habit the bash-path rewrite can't reach, so the PNG273    lands at /workspace, not the scanned sandbox dir). Scan the given dirs for media modified THIS turn274    (mtime > since_ts) so a fresh session never re-shows a prior session's files (concurrency_limit=1 => one run275    at a time, race-free). Dirs that don't exist (e.g. /workspace on Windows) are skipped; the sandbox is excluded."""276    msgs = []277    outdir = tempfile.mkdtemp(prefix="codeagent_out_")278    exclude = os.path.abspath(exclude_dir) if exclude_dir else None279    for d in dirs:280        if not (d and os.path.isdir(d)) or (exclude and os.path.abspath(d) == exclude):281            continue282        for p in sorted(glob.glob(os.path.join(d, "**", "*"), recursive=True))[:40]:283            if not os.path.isfile(p):284                continue285            try:286                if os.path.getmtime(p) <= since_ts:287                    continue288            except OSError:289                continue290            b = _render_media_file(p, outdir)291            if b is not None:292                msgs.append(b)293    return msgs294 295 296def _media_messages(ws, since=0.0):297    """Scan the persistent workspace and turn whatever the agent PRODUCED/CHANGED THIS TURN into inline298    chat messages (gr.Chatbot type='messages'): images/gifs/charts (png/jpg/jpeg/gif/bmp/webp) -> gr.Image,299    .svg -> inline gr.HTML, videos -> gr.Video, .html -> a live sandboxed iframe via gr.HTML. Non-media300    files are ignored. `since` is an mtime watermark: only files modified301    after it are shown, so prior turns' artifacts are NOT re-rendered. Returns (msgs, new_watermark).302    Nothing is hard-coded to a task."""303    msgs, watermark = [], since304    if not (ws and os.path.isdir(ws)):305        return msgs, watermark306    outdir = tempfile.mkdtemp(prefix="codeagent_out_")   # copy out so Gradio can serve a stable path307    for p in sorted(glob.glob(os.path.join(ws, "**", "*"), recursive=True))[:40]:308        if not os.path.isfile(p):309            continue310        try:311            mt = os.path.getmtime(p)312        except OSError:313            continue314        if mt > watermark:315            watermark = mt316        if mt <= since:                                  # produced/changed in an EARLIER turn -> skip317            continue318        b = _render_media_file(p, outdir)   # png/jpg/.. -> Image; svg/html -> sandboxed iframe; video -> Video319        if b is not None:320            msgs.append(b)321    return msgs, watermark322 323 324def _new_session():325    """A fresh per-browser-session state. The DISPLAYED chat lives in `chat` here (gr.State) so the326    Chatbot is purely an OUTPUT - component-valued media bubbles never round-trip through the Chatbot's327    preprocess. `mtime` is the media watermark; `sandbox`/`history` persist the agent workspace + convo;328    `token` is the Gradio session_hash so demo.unload can clean THIS session's sandbox on leave."""329    return {"sandbox": None, "history": None, "chat": [], "mtime": 0.0, "token": None}330 331 332# When the user clicks "🔄 New" mid-generation, Gradio's cancels= stops THIS run generator (frees the UI for333# the fresh session). reset_session ALSO flips this per-token flag so, if the generator yields once more before334# cancellation lands, its live indicator switches from "Ns..." to "cancel Ns..." - the user sees New registered335# and the old turn aborting. (run_agent has no cooperative abort hook, so the background worker finishes the336# current turn on its own; its result is simply discarded because the session was reset.)337_CANCEL_FLAGS = {}   # session token -> threading.Event (set => the in-flight run for this session is cancelling)338 339 340def _cancel_event(sess):341    tok = (sess or {}).get("token")342    if not tok:343        return None344    ev = _CANCEL_FLAGS.get(tok)345    if ev is None:346        ev = _CANCEL_FLAGS[tok] = threading.Event()347    return ev348 349 350_WORD_RE = _re.compile(r"[A-Za-z]{3,}")   # an alphabetic word of length >= 3351 352 353def _is_gibberish(task):354    """CONSERVATIVE frontend guard ONLY (does NOT alter the model's act-not-ask behavior on real tasks): treat355    a task as gibberish if it is too short or has no real word - e.g. an accidental 'dd'. Anything with a356    >=3-letter alphabetic word (a genuine short request like 'plot sin x') is NOT gibberish and runs normally."""357    t = (task or "").strip()358    if len(t.replace(" ", "")) <= 3:                 # <= 3 non-space chars (e.g. "dd", "ok", "?!")359        return True360    if len(set(t.replace(" ", ""))) <= 1:            # a single repeated char (e.g. "aaaa")361        return True362    if not _WORD_RE.search(t):                        # no alphabetic word of length >= 3 anywhere363        return True364    return False365 366 367def run(task, sess, request: gr.Request = None):368    """One turn (STREAMING generator): append the user's task + the agent's trajectory and any produced media369    INLINE in the chat. The displayed chat is driven from gr.State (sess['chat']); the Chatbot itself is never370    read as input. The blocking agent loop runs in a worker thread while we yield a live elapsed-time indicator371    ("Ns...") so the UI stays responsive AND so Gradio has yield points to CANCEL on (the "🔄 New" button uses372    cancels= to abort this generator)."""373    sess = sess or _new_session()374    if request is not None and not sess.get("token"):375        sess["token"] = getattr(request, "session_hash", None)376    task = (task or "").strip()377    chat = list(sess.get("chat") or [])378    if not task:379        yield chat, sess, ""380        return381    # Frontend gibberish guard: don't burn the agent loop on an accidental "dd". Keep the typed input so the382    # user can fix it; just nudge (transient - not persisted to sess['chat']) for a real task. (Conservative -383    # real short requests still run; see _is_gibberish.)384    if _is_gibberish(task):385        nudge = chat + [{"role": "assistant",386                         "content": "What would you like me to build or run? Describe a coding task."}]387        yield nudge, sess, gr.update()388        return389    # html.escape the user's text for DISPLAY only (the agent still gets the raw `task`): with sanitize_html390    # off, an unescaped angle-bracket the user types (e.g. "<h1>" in a coding question, or a pasted snippet)391    # would otherwise be interpreted as live HTML in the bubble - vanishing from view (or, for <script>/392    # onerror, executing). Escaping shows it verbatim and closes the user-input injection path.393    chat = chat + [{"role": "user", "content": html.escape(task)}]394    cancel = _cancel_event(sess)395    if cancel is not None:396        cancel.clear()                             # fresh turn: not cancelling (yet)397    # run_agent returns the FULL cumulative conversation (it prepends list(history) + [task]); record where398    # this turn's new messages start so we render ONLY the just-produced turn, not all of history every time.399    prev = sess.get("history") or []400    base = len(prev)401 402    holder = {}403    def _on_step(msgs, partial=None):              # STREAM: snapshot trajectory-so-far + the in-progress generation404        holder["partial"] = list(msgs)             # shallow copy: the worker keeps appending to `msgs`405        holder["gen"] = partial                    # token-level: the text being generated NOW (None once the step lands)406    def _work():407        try:408            # MULTI-TURN: reuse the session's sandbox + conversation so files persist and you can iterate.409            # n_predict=32768: give the model room for full multi-step reasoning (the run_agent default of 1024410            # truncates the <think> trace mid-thought). ctx stays the env value (131072), so the prompt budget411            # is ctx - n_predict and long sessions still fit.412            holder["res"] = agent.run_agent(SERVER, TOK, task, max_iters=MAX_ITERS, temperature=0.0,413                                            n_predict=NPRED, on_step=_on_step,414                                            keep_workspace=True, sandbox=sess.get("sandbox"), history=sess.get("history"))415        except Exception as e:                     # noqa: BLE001 - surfaced to the user below416            holder["err"] = e417    worker = threading.Thread(target=_work, daemon=True)418    worker.start()419 420    # Live indicator while the worker runs: a transient assistant bubble appended ONLY to the yielded chat421    # (never persisted to sess['chat']). Switches to a "cancel Ns..." readout once 🔄 New flips the flag.422    t0 = time.time()423    while worker.is_alive():424        worker.join(timeout=1.0)425        if not worker.is_alive():426            break427        secs = int(time.time() - t0)428        cancelling = cancel is not None and cancel.is_set()429        label = f"cancel {secs}s... (starting a new chat)" if cancelling else f"working... {secs}s"430        foot = f'<span class="live-ind"><span class="live-cursor">▍</span> {label}</span>'431        # STREAM: show the trajectory produced SO FAR (each completed step appears live: tool call -> its output432        # -> next call), with the working footer beneath it - instead of a bare timer for the whole run. partial433        # is set by _on_step; partial[base:] mirrors the FINAL render slice (line below) so the view is consistent.434        partial = holder.get("partial")435        gen = holder.get("gen")436        parts = []437        if partial is not None and len(partial) > base + 1:438            parts.append(_render(partial[base:]))439        if gen:                                    # token-level: the generation typing out now (escaped, muted, tail)440            # split think vs answer on the </think> boundary (the backend marks it) so the live <think> is clearly441            # labelled WHILE reasoning, then COLLAPSES to a small tag once done - so it's never mistaken for the answer.442            _think, _sep, _ans = gen.partition("</think>")443            _think = _think.replace("<think>", "").strip()444            cur = '<span class="live-cursor">▍</span>'445            if not _sep:                           # still reasoning446                parts.append(f'<div class="live-gen"><span class="live-think-tag">▍ thinking</span> '447                             f'{html.escape(_think[-600:])}{cur}</div>')448            else:                                  # reasoning done -> collapse think to a tag, show the answer typing449                ans = html.escape(_ans.strip()[-700:])450                parts.append(f'<div class="live-think-tag">▍ reasoned</div>'451                             f'<div class="live-answer">{ans}{cur}</div>')452        parts.append(foot)453        yield chat + [{"role": "assistant", "content": "\n\n".join(parts)}], sess, ""454 455    if "err" in holder:456        e = holder["err"]457        chat = chat + [{"role": "assistant", "content":458                        ("⚠️ The agent couldn't finish this one - a 1B on free CPU can struggle on hard tasks. "459                         "Try rephrasing, or click the 🗑 trash icon (top-right of the chat) for a fresh session.\n\n"460                         f"`{type(e).__name__}: {str(e)[:300]}`")}]461        sess["chat"] = chat462        yield chat, sess, ""463        return464    res = holder["res"]465    sess["sandbox"] = res.get("sandbox")          # persist workspace + history for the NEXT turn (iterate further)466    sess["history"] = res.get("messages")467    _register_sandbox(sess)                        # so demo.unload can rmtree THIS session's dir on leave468    transcript = _render(res["messages"][base:])   # only THIS turn's new assistant + tool messages469    header = (f"_iters={res.get('iters')} · tool-calls={res.get('tool_calls_made')} · "470              f"{res.get('tool_counts')}_\n\n")471    # token-speed readout: a small, muted, right-aligned line at the END of the assistant turn (Gradio has no472    # native t/s widget, so render it as styled HTML appended to the final text bubble). Omit if missing/zero.473    tps = res.get("tps") or {}474    tg, pp = tps.get("tg") or 0, tps.get("pp") or 0475    speed = ""476    if tg > 0:477        pp_part = f" - PP {pp:.0f} tok/s" if pp > 0 else ""478        speed = (f'\n\n<div style="text-align:right;font-size:0.78em;opacity:0.55">'479                 f'TG {tg:.0f} tok/s{pp_part}</div>')480    chat = chat + [{"role": "assistant", "content": header + transcript + speed}]481    # matplotlib (MPLBACKEND=Agg) saves plots to PNG files, so charts flow through this same inline path.482    # mtime watermark: only files produced/changed THIS turn become bubbles (no re-showing prior artifacts).483    media, sess["mtime"] = _media_messages(res.get("workspace"), since=sess.get("mtime") or 0.0)484    # ALSO catch media the model wrote to an ABSOLUTE path OUTSIDE the per-session sandbox (e.g. a savefig to485    # '/workspace/chart.png' inside the script - the bash-path rewrite can't reach in-code paths). this-turn-only486    # (mtime > t0, the run start) so no cross-session leak. /workspace is the dominant Claude-Code habit.487    media = media + _extra_media(["/workspace"], since_ts=t0, exclude_dir=res.get("workspace"))488    # SALVAGE: a 1B often pastes a full HTML page into its prose answer instead of write()-ing an .html file489    # (so _media_messages finds no file to iframe). If no .html was written this turn but the answer/transcript490    # contains a full HTML document, render it LIVE anyway so the web-page example still works.491    _ws = res.get("workspace")492    if not (_ws and glob.glob(os.path.join(_ws, "**", "*.html"), recursive=True)):493        _doc, _warn = _salvage_html(res.get("final", ""))494        if not _doc:495            _doc, _warn = _salvage_html(transcript)496        if _doc:497            media = media + [_media_bubble(gr.HTML(_html_iframe(_doc)))]498            if _warn:                              # surface the truncation so the user knows WHY it looks broken499                media = media + [{"role": "assistant", "content": f"⚠️ HTML note: {_warn}"}]500    chat = chat + media501    sess["chat"] = chat                           # persist the displayed chat in gr.State (drives the Chatbot)502    yield chat, sess, ""503 504 505def run_coding_task(task: str) -> str:506    """Run a coding task with the MiniCPM5-1B agent and return the final answer.507 508    The agent writes, runs, and fixes code in a fresh sandbox (the write -> run -> verify loop),509    then returns its final answer as plain text. This is the MCP-callable entrypoint: an external510    MCP client (Claude Desktop, Cursor, Cline, ...) can call it to have the agent complete a coding511    task. Each call is independent and uses its own ephemeral workspace (no shared session state),512    so it is safe to call concurrently with the web UI.513 514    Args:515        task (str): A natural-language coding request, e.g. "Write a Python script that makes a bar516            chart of 30, 45, 25 labeled A, B, C, saves it as chart.png, then run it."517 518    Returns:519        str: The agent's final answer text. Note: any files the agent produces (charts, pages) live520            in its ephemeral sandbox and are not returned by this MCP tool, only the textual answer.521    """522    task = (task or "").strip()523    if not task:524        return "Please provide a coding task to run."525    try:526        # Own sandbox per call (sandbox=None), cleaned up afterward (keep_workspace=False). Same527        # n_predict=32768 as the UI so reasoning is not truncated.528        res = agent.run_agent(SERVER, TOK, task, max_iters=MAX_ITERS, temperature=0.0,529                              n_predict=NPRED, keep_workspace=False)530    except Exception as e:                         # noqa: BLE001 - surfaced to the MCP client as text531        return f"The agent could not finish this task: {type(e).__name__}: {str(e)[:300]}"532    return _clean(res.get("final") or "") or "(the agent finished without a textual answer)"533 534 535def reset_session(sess):536    """Drop the persistent workspace + history and start a FRESH session (its own new sandbox dir). If a turn537    is in flight, flip its cancel flag so the live indicator shows 'cancel Ns...' (the run generator is also538    aborted by cancels= on the button); carry the session token into the new state so cancels= keeps targeting539    the right session."""540    tok = (sess or {}).get("token")541    ev = _CANCEL_FLAGS.get(tok) if tok else None542    if ev is not None:543        ev.set()                         # signal the in-flight run to show 'cancelling' before cancels= lands544    try:545        if sess and sess.get("sandbox") is not None:546            sess["sandbox"].cleanup()    # rmtree the old per-session workspace so files never leak/accumulate547    except Exception:548        pass549    fresh = _new_session()550    fresh["token"] = tok                 # same browser session keeps its token (clean flag registry lookup)551    if ev is not None:552        ev.clear()                       # the old turn is gone; the fresh session starts un-cancelled553    return [], fresh, ""554 555 556MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024   # reject files larger than ~2GB557 558 559def attach_file(filepath, task, sess, request: gr.Request = None):560    """DRAG-DROP / attach (ChatGPT-style): save the dropped file INTO the current session's sandbox561    workspace and APPEND its '/workspace/<name>' path as plain text to the chat input, so the agent562    sees the path and the user can still edit/remove it. Returns (updated_textbox, sess, status_html)."""563    sess = sess or _new_session()564    if request is not None and not sess.get("token"):565        sess["token"] = getattr(request, "session_hash", None)566    task = task or ""567    if not filepath:568        return gr.update(), sess, ""569    try:570        size = os.path.getsize(filepath)571    except OSError:572        return gr.update(), sess, "⚠️ couldn't read the dropped file"573    if size > MAX_UPLOAD_BYTES:574        return gr.update(), sess, f"⚠️ file too large ({size // (1024*1024)} MB) - max 2 GB"575    # ensure THIS session owns a sandbox (its own fresh per-session working dir) before writing into it.576    if sess.get("sandbox") is None:577        sess["sandbox"] = agent.Sandbox()578    sb = sess["sandbox"]579    _register_sandbox(sess)                        # so demo.unload can rmtree THIS session's dir on leave580    name = os.path.basename(filepath)581    try:582        dst = sb._resolve(name)583        shutil.copy2(filepath, dst)584    except Exception as e:585        return gr.update(), sess, f"⚠️ couldn't save the file: {type(e).__name__}"586    # don't let the just-uploaded file get rendered as a "produced" media bubble next turn.587    try:588        mt = os.path.getmtime(dst)589        if mt > (sess.get("mtime") or 0.0):590            sess["mtime"] = mt591    except OSError:592        pass593    ref = f"/workspace/{name}"594    new_task = (task.rstrip() + ("  " if task.strip() else "") + ref).lstrip()595    return new_task, sess, f"📎 added `{ref}` ({size // 1024 if size < 1024*1024 else size // (1024*1024)}"\596                           f"{' KB' if size < 1024*1024 else ' MB'}) - edit or remove the path before sending"597 598 599# ── Session isolation: this Space is SHARED (one container, many browser sessions). Each session gets600# its OWN tempfile.mkdtemp sandbox (agent.Sandbox), so files never leak/accumulate between users. We also601# sweep ORPHANED sandbox temp dirs (a user who left without firing demo.unload, e.g. a crashed tab) older602# than SANDBOX_TTL_S, so disk doesn't fill up on the free 2-vCPU tier.603SANDBOX_TTL_S = int(os.environ.get("CODEAGENT_SANDBOX_TTL", str(30 * 60)))   # ~30 min604_SWEEP_PREFIXES = ("agent_ws_", "codeagent_out_")   # Sandbox dirs + the copied-out media dirs605 606 607def _sweep_orphans():608    """rmtree any sandbox/media temp dirs older than SANDBOX_TTL_S (by mtime). Best-effort; never raises."""609    root = tempfile.gettempdir()610    cutoff = time.time() - SANDBOX_TTL_S611    try:612        names = os.listdir(root)613    except OSError:614        return615    for n in names:616        if not n.startswith(_SWEEP_PREFIXES):617            continue618        p = os.path.join(root, n)619        try:620            if os.path.isdir(p) and os.path.getmtime(p) < cutoff:621                shutil.rmtree(p, ignore_errors=True)622        except OSError:623            pass624 625 626def _sweeper_loop():627    while True:628        time.sleep(SANDBOX_TTL_S // 3 or 600)629        _sweep_orphans()630 631 632threading.Thread(target=_sweeper_loop, daemon=True).start()633 634 635# Per-session sandbox registry so demo.unload can clean THE session that just left. Gradio's unload636# handler can't receive gr.State, so we map a session token -> its Sandbox and clean by token on unload.637# (The TTL sweeper is the backstop for any session that leaves without firing unload, e.g. a crashed tab.)638_SESSION_SANDBOXES = {}   # token -> agent.Sandbox639 640 641def _register_sandbox(sess):642    if sess and sess.get("sandbox") is not None and sess.get("token"):643        _SESSION_SANDBOXES[sess["token"]] = sess["sandbox"]644 645 646def on_unload(request: gr.Request):647    """Fires when a browser session ENDS (tab closed / navigated away, possibly mid-run). Clean up ONLY648    that session's sandbox dir - NEVER the shared llama-server (other users depend on it)."""649    try:650        tok = getattr(request, "session_hash", None)651        sb = _SESSION_SANDBOXES.pop(tok, None)652        if sb is not None:653            sb.cleanup()654        _CANCEL_FLAGS.pop(tok, None)     # drop this session's cancel flag too655    except Exception:656        pass657    _sweep_orphans()    # also clear anything else already past TTL658 659 660# ── Custom theme: "Solder & Amber" - a warm-charcoal WORKBENCH look ────────────────────────────────661# Built on gr.themes.Base (minimal defaults -> we own the palette). Density comes from the SIZE TOKENS662# (spacing_sm / radius / text_sm) and tight padding tokens; the css= block below adds the workbench663# texture, role-by-gutter chat treatment, and micro-interactions the tokens can't express. Palette:664#   ACCENT  #E8932B  amber  (the single action color: primary button, focus ring, links, gutter rail)665#   SUCCESS #5E8C7D  oxidized teal (reserved for "verify passed")  ·  base charcoal #1A1614, text parchment.666from gradio.themes.utils import colors, sizes, fonts667 668# "Solder & Amber" - a warm-charcoal WORKBENCH theme (a tiny 1B running real bash/python on a free CPU reads669# as a powered-on workbench, not a cloud chatbot). Palette: charcoal #1A1614 base, taupe-brown #3A2F27 raised670# surface, amber #E8932B the single action color, oxidized teal #5E8C7D reserved ONLY for "verify passed",671# parchment #E8DDD0 / taupe #C8B6A6 text. We set the LIGHT and DARK token variants to the SAME values so the672# Gradio theme toggle can't drift the look - it is one cohesive workbench in either mode (and that collapses673# the old body:not(.dark)/body.dark CSS duality, killing a whole class of contrast bugs).674THEME = gr.themes.Base(675    primary_hue=colors.orange,       # amber action family676    secondary_hue=colors.orange,677    neutral_hue=colors.stone,        # warm neutral (charcoal/taupe), not cool slate678    spacing_size=sizes.spacing_sm,679    radius_size=sizes.radius_sm,     # tighter, more engineered corners than the old radius_lg680    text_size=sizes.text_md,681    # Anti-AI-slop type: an industrial humanist grotesk for chrome (NOT Inter/Roboto/system), and a condensed682    # engineered mono with a vintage-terminal feel for code/tool-calls/transcript. Both load from Google Fonts.683    font=(fonts.GoogleFont("Hanken Grotesk"), "ui-sans-serif", "system-ui", "sans-serif"),684    font_mono=(fonts.GoogleFont("Martian Mono"), "ui-monospace", "monospace"),685).set(686    body_background_fill="#1A1614",687    body_background_fill_dark="#1A1614",688    background_fill_primary="#1A1614",689    background_fill_primary_dark="#1A1614",690    background_fill_secondary="#241E1A",691    background_fill_secondary_dark="#241E1A",692    body_text_color="#E8DDD0",693    body_text_color_dark="#E8DDD0",694    body_text_color_subdued="#C8B6A6",695    body_text_color_subdued_dark="#C8B6A6",696    block_title_text_color="#E8DDD0",697    block_title_text_color_dark="#E8DDD0",698    block_label_text_color="#C8B6A6",699    block_label_text_color_dark="#C8B6A6",700    border_color_primary="#3A2F27",701    border_color_primary_dark="#3A2F27",702    # Amber action, dark text-on-amber for crisp contrast.703    button_primary_background_fill="#E8932B",704    button_primary_background_fill_hover="#F2A23E",705    button_primary_background_fill_dark="#E8932B",706    button_primary_background_fill_hover_dark="#F2A23E",707    button_primary_text_color="#1A1614",708    button_primary_text_color_dark="#1A1614",709    button_secondary_background_fill="transparent",710    button_secondary_background_fill_hover="#2E251F",711    button_secondary_background_fill_dark="transparent",712    button_secondary_background_fill_hover_dark="#2E251F",713    button_secondary_text_color="#C8B6A6",714    button_secondary_text_color_dark="#C8B6A6",715    button_secondary_border_color="#3A2F27",716    button_secondary_border_color_dark="#3A2F27",717    # Recessed input well, amber focus ring.718    input_background_fill="#241E1A",719    input_background_fill_dark="#241E1A",720    input_border_color="#3A2F27",721    input_border_color_dark="#3A2F27",722    input_border_color_focus="#E8932B",723    input_border_color_focus_dark="#E8932B",724    # Depth from deep warm shadows (dark theme).725    shadow_drop="0 1px 2px 0 rgba(0,0,0,0.30)",726    shadow_drop_lg="0 6px 20px -4px rgba(0,0,0,0.45)",727    block_background_fill="#1A1614",728    block_background_fill_dark="#1A1614",729    block_border_width="0px",730    block_shadow="none",731    block_label_background_fill="transparent",732    block_label_background_fill_dark="transparent",733    block_padding="6px",734    button_large_padding="9px 16px",735    button_small_padding="6px 12px",736    form_gap_width="0px",737    link_text_color="#E8932B",738    link_text_color_dark="#E8932B",739    link_text_color_hover="#F2A23E",740)741 742# css = only the true deltas the theme tokens can't express. ChatGPT-style bubbles, a no-gap single-page743# column, a thin scrollbar, and one terse status line. Targets stable elem_id / elem_classes (per the744# Gradio custom-CSS guidance) rather than fragile internal selectors.745CSS = """746/* ============================================================================747   "SOLDER & AMBER" - one cohesive warm-charcoal WORKBENCH skin. The theme tokens748   set the light AND dark variants identically, so NONE of these rules need a749   body.dark / body:not(.dark) guard (that duality was a contrast-bug factory).750   ROLES are distinguished by SIDE + an amber GUTTER, never by an alternating751   background fill - that alternation was the "green/gray inconsistency" the user752   saw. Accent amber #E8932B = the only action color; oxidized teal #5E8C7D is753   reserved for success ("verify passed"); parchment #E8DDD0 / taupe #C8B6A6 text.754   ============================================================================ */755 756/* ---- Page canvas: warm charcoal + an engineered dot-grid + a faint warm vignette757   and barely-there grain, so the wide side-margins and an empty thread read as758   graph-paper on a workbench, never a blank slab. Fixed so it doesn't scroll.759   The Gradio container is made transparent so this texture shows through behind760   and around the 880px column (incl. behind the flat, gutter-only agent turns). */761body {762  background-color: #1A1614 !important;763  background-image:764    radial-gradient(circle at center, rgba(220,182,140,0.17) 1.1px, transparent 1.7px),765    radial-gradient(135% 95% at 50% -8%, rgba(232,147,43,0.10), transparent 60%),766    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");767  background-size: 24px 24px, 100% 100%, 150px 150px;768  background-position: 0 0, 0 0, 0 0;769  background-attachment: fixed, fixed, fixed;770  background-repeat: repeat, no-repeat, repeat;771}772.gradio-container { background: transparent !important; }773 774/* Global text: parchment everywhere by default; muted taupe for secondary; amber for links.775   (Theme tokens already do most of this; these pins stop any stray white-on-charcoal.) */776.gradio-container, .prose, .prose *, p, li, span, label, .markdown, .markdown *,777#chat .message, #chat .message * { color: #E8DDD0; }778small, #chat summary, .ft-sep, #appfooter span { color: #C8B6A6; }779a, .markdown a, #chat a, #appfooter a { color: #E8932B; }780a:hover, #appfooter a:hover { color: #F2A23E; }781 782/* App column: centered, capped, app-like (not a landing page). */783.gradio-container { max-width: 880px !important; margin: 0 auto !important; padding: 2px 12px 4px !important; }784footer { display: none !important; }                          /* drop the default Gradio footer */785.gradio-container > .main, .gradio-container .contain { padding-top: 0 !important; gap: 4px !important; }786.gradio-container .contain > *:first-child { margin-top: 0 !important; }787#dropzone-host { padding: 0 !important; margin: 0 !important; min-height: 0 !important; height: 0 !important;788    overflow: visible !important; border: none !important; }789/* tight, compact column - minimal gaps between chat / composer / examples / footer */790.gradio-container .gap { gap: 4px !important; }791.gradio-container .form { gap: 4px !important; }792#composer { gap: 6px !important; align-items: flex-start !important; margin: 2px 0 0 !important; }793 794/* ---- Web-tools badge (now lives in the footer, not a top strip) ---- */795.badge { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.04em;796  padding: 1px 8px; border-radius: 999px; border: 1px solid; text-transform: uppercase; }797.badge-on  { color: #8FBFAE; border-color: #355247; background: rgba(94,140,125,0.10); }798.badge-off { color: #A89A8B; border-color: #3A2F27; background: transparent; }799 800/* ---- Chat thread ---- */801#chat { border: none !important; box-shadow: none !important; background: transparent !important; }802#chat .message-row { padding: 0 !important; }803#chat .message {804  border: none !important; line-height: 1.6 !important; padding: 11px 15px !important;805  animation: fadeRise 0.16s ease-out both;                    /* every bubble "arrives" */806}807/* USER turn: ONE bubble. Gradio NESTS .message.user (outer) > [data-testid="user"] (inner); styling BOTH gave808   a DOUBLE box. So the OUTER .message.user is the single taupe bubble (right-aligned, hugs its text via809   fit-content + the faint amber top-edge), and the INNER [data-testid="user"] is FLATTENED (transparent, no810   box/padding) so it just holds the text. */811#chat .message.user {812  margin-left: auto !important; width: fit-content !important; max-width: 86% !important;813  background: #3A2F27 !important; color: #E8DDD0 !important;814  padding: 9px 14px !important;815  border-radius: 11px !important; border-top: 1px solid rgba(232,147,43,0.40) !important;816  box-shadow: 0 1px 2px rgba(0,0,0,0.30) !important;817}818#chat .message.user [data-testid="user"], #chat [data-testid="user"] {819  background: transparent !important; border: none !important; border-radius: 0 !important;820  box-shadow: none !important; padding: 0 !important; margin: 0 !important;821}822/* tighten the markdown <p> inside the user bubble so a short message isn't a tall box */823#chat .message.user p, #chat [data-testid="user"] p { margin: 0 !important; }824#chat .message.user *, #chat [data-testid="user"] * { color: #E8DDD0 !important; }825/* ASSISTANT turn: left-aligned, NO fill - sits flat on the dotted grid, marked ONLY by a 2px amber gutter826   (the "machine output" rail). Text + media + warnings are ALL bot bubbles, so they share this gutter and a827   variable number of them still reads as ONE consistent agent turn (no more transparent-media gaps). */828#chat .message.bot, #chat [data-testid="bot"], #chat .message.component, #chat .message.html {829  margin-right: auto !important; max-width: 100% !important;830  background: transparent !important; color: #E8DDD0 !important;831  border-left: 2px solid #E8932B !important; border-radius: 0 8px 8px 0 !important;832  padding: 9px 14px 9px 15px !important; box-shadow: none !important;833}834#chat .message.bot *, #chat [data-testid="bot"] * { color: #E8DDD0; }835/* media (image / svg / video / iframe) bubbles: keep the amber gutter, drop padding so the artifact fills. */836#chat .message:has(img), #chat .message:has(video), #chat .message:has(iframe), #chat .message:has(.lp-frame) {837  padding: 6px 6px 6px 14px !important; background: transparent !important;838}839#chat img, #chat video { border-radius: 8px !important; }840/* live indicator: a blinking amber block-cursor + muted italic readout (not a "real" gray bubble popping in). */841.live-ind { font-style: italic; color: #C8B6A6; font-family: var(--font-mono); font-size: 13px; }842.live-cursor { color: #E8932B; font-weight: 700; animation: blink 1.05s steps(1) infinite; }843/* token-level streaming: the generation typing out (muted, monospace, wraps; the tail of the in-progress text) */844.live-gen { font-family: var(--font-mono); font-size: 12px; color: #9C8C7C; white-space: pre-wrap;845  word-break: break-word; opacity: 0.9; border-left: 2px solid #3A2F27; padding-left: 10px; margin: 2px 0; }846/* think label (small amber tag) vs the live answer (brighter than the muted think) so they're never confused */847.live-think-tag { font-family: var(--font-mono); font-size: 11px; color: #E8932B; opacity: 0.8; font-style: italic; margin: 2px 0; }848.live-answer { font-family: var(--font-mono); font-size: 13px; color: #E8DDD0; white-space: pre-wrap;849  word-break: break-word; border-left: 2px solid #E8932B; padding-left: 10px; margin: 2px 0; }850 851/* code + inline code: dark warm fill, mono, parchment text. */852#chat pre, #chat pre code, #chat code, #chat :not(pre) > code {853  background-color: #120F0D !important; color: #EBD9C2 !important;854  border: 1px solid #2A2219 !important; border-radius: 7px !important; font-family: var(--font-mono) !important;855}856#chat pre { padding: 10px 12px !important; }857/* per-turn meta header (iters/tool-calls) + token-speed: quiet mono so they read as a stats strip, not prose. */858#chat .message.bot em:first-child { color: #A89A8B; font-style: normal; font-family: var(--font-mono); font-size: 12px; }859 860/* <details> thinking / tool-output: etched panel, [+]/[-] toggle instead of a triangle, amber-tinted summary. */861#chat details {862  margin: 6px 0 !important; background-color: #1E1813 !important;863  border: 1px solid #2E251F !important; border-left: 2px solid #5E4A35 !important;864  border-radius: 7px !important; padding: 5px 10px !important;865}866#chat details, #chat details * { color: #D7C9B8 !important; }867#chat details pre, #chat details code { background-color: #120F0D !important; color: #EBD9C2 !important; }868#chat summary { cursor: pointer; font-size: 0.9em; color: #C8B6A6 !important; list-style: none;869  font-family: var(--font-mono); }870#chat summary::-webkit-details-marker { display: none; }871#chat summary::before { content: "[+] "; color: #E8932B; font-family: var(--font-mono); }872#chat details[open] > summary::before { content: "[-] "; }873/* copy buttons on code/messages: readable warm chip. */874#chat .message button, #chat .copy-button, #chat button[title*="opy"], #chat .code_wrap button, #chat [class*="copy"] {875  color: #D7C9B8 !important; background-color: #2A2219 !important;876  border: 1px solid #3A2F27 !important; border-radius: 6px !important; opacity: 1 !important;877}878 879/* ---- Inline HTML "live preview": wrap the sandboxed iframe in a tiny browser-chrome frame so the running880   mini-app reads as a real artifact, not a floating rectangle. ---- */881.lp-frame { border: 1px solid #3A2F27 !important; border-radius: 9px; overflow: hidden;882  box-shadow: 0 6px 20px -6px rgba(0,0,0,0.5); background: #0E0C0A; }883.lp-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px;884  background: #221C17; border-bottom: 1px solid #2E251F; }885.lp-bar .lp-dot { width: 9px; height: 9px; border-radius: 50%; background: #E8932B;886  box-shadow: 0 0 6px rgba(232,147,43,0.6); }887.lp-bar .lp-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em;888  text-transform: uppercase; color: #A89A8B; }889.lp-frame iframe { display: block; width: 100%; height: 480px; border: 0; background: #ffffff; }890 891/* ---- Composer: amber focus ring + a busy "running" state on Send ---- */892#prompt textarea {893  border-radius: 11px !important; padding: 11px 14px !important; font-size: 15px !important;894  background: #241E1A !important; box-shadow: inset 0 1px 2px rgba(0,0,0,0.35) !important; color: #E8DDD0 !important;895}896#prompt textarea::placeholder { color: #9C8E7E !important; }  /* >=4.5:1 on the #241E1A well (WCAG AA) */897#prompt textarea:focus { box-shadow: 0 0 0 2px rgba(232,147,43,0.30) !important; }898/* In Gradio 6 a gr.Button's elem_id lands on the <button> itself, so #send IS the button (the old899   "#send button" descendant selector matched nothing -> the mono styling was dead CSS). Target #send. */900#send {901  flex: 0 0 auto !important; width: auto !important; min-width: 0 !important;902  max-width: 104px !important; white-space: nowrap !important;903  border-radius: 9px !important; font-size: 14px !important; font-weight: 700 !important;904  font-family: var(--font-mono) !important; letter-spacing: 0.03em !important;905  padding: 9px 14px !important; color: #1A1614 !important; transition: filter 0.14s ease, transform 0.06s ease;906}907#send:hover { filter: brightness(1.06); }908#send:active { transform: translateY(1px); }909#send:disabled { opacity: 0.6 !important; cursor: progress !important; filter: none !important; }910 911/* ---- Example prompts: etched warm pills, amber border + lift on hover ---- */912#examples { border: none !important; background: transparent !important; padding: 0 !important; margin: 2px 0 0 !important; }913#examples .examples-table, #examples table, #examples tbody, #examples tr {914  border: none !important; display: flex !important; flex-wrap: wrap !important; gap: 6px !important;915}916#examples button, #examples td {917  border-radius: 8px !important; border: 1px solid #3A2F27 !important;918  background: #221C17 !important; color: #C8B6A6 !important;919  box-shadow: inset 0 1px 0 rgba(255,255,255,0.02) !important;920  font-family: var(--font-mono) !important; font-size: 12.5px !important; line-height: 1.3 !important;921  padding: 6px 12px !important; margin: 0 !important; max-width: 360px !important;922  white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important;923  transition: border-color 0.14s ease, color 0.14s ease, transform 0.08s ease;924}925#examples button:hover, #examples td:hover {926  border-color: #E8932B !important; color: #E8DDD0 !important; transform: translateY(-1px);927}928 929/* ---- Footer: ONE ultra-quiet line carrying real signal - model link + the (otherwise invisible) MCP endpoint. */930#footer-host { padding: 0 !important; margin: 4px 0 0 !important; }931#appfooter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;932  border-top: 1px solid #2A2219; padding: 9px 4px 4px; font-size: 11.5px;933  font-family: var(--font-mono); color: #8C7D6E; letter-spacing: 0.01em; }934#appfooter code { background: #1E1813; border: 1px solid #2E251F; border-radius: 5px;935  padding: 1px 5px; font-size: 11px; color: #C8B6A6; }936#appfooter .ft-sep { color: #6E6055; }   /* faintly visible separators (were 2.23:1, effectively invisible) */937#appfooter .ft-mark { color: #E8932B; font-weight: 700; letter-spacing: -1px; }   /* small amber identity mark */938#appfooter .ft-name { color: #C8B6A6; }939 940/* ---- Attach control (hidden) + amber drag-anywhere overlay ---- */941#dropfile { display: none !important; }942#attachstatus { font-size: 12.5px !important; color: #C8B6A6 !important; margin: 0 !important; padding: 0 !important; text-align: center; }943#attachstatus:empty { display: none !important; }944#dropzone { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: center;945  background: rgba(232,147,43,0.08); backdrop-filter: blur(2px); pointer-events: none; }946#dropzone.drag-active { display: flex; }947#dropzone .dz-inner { font-size: 17px; font-weight: 600; color: #E8932B; text-align: center; line-height: 1.6;948  font-family: var(--font-mono); border: 2px dashed #E8932B; border-radius: 14px; padding: 34px 46px;949  background: #1A1614; animation: pulseBorder 1.4s ease-in-out infinite; }950 951/* ---- Thin warm scrollbar ---- */952* { scrollbar-width: thin; scrollbar-color: rgba(120,104,88,0.4) transparent; }953*::-webkit-scrollbar { width: 7px; height: 7px; }954*::-webkit-scrollbar-thumb { background: rgba(120,104,88,0.4); border-radius: 6px; }955*::-webkit-scrollbar-thumb:hover { background: rgba(120,104,88,0.65); }956 957/* ---- Motion (all subtle, sub-200ms; fully disabled under prefers-reduced-motion) ---- */958@keyframes fadeRise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }959@keyframes blink { 50% { opacity: 0.15; } }960@keyframes pulseBorder { 0%,100% { border-color: #E8932B; } 50% { border-color: #8a5a1c; } }961@media (prefers-reduced-motion: reduce) {962  *, *::before, *::after { animation: none !important; transition-duration: 0.01ms !important; }963}964"""965 966# Tiny JS: a ChatGPT-style DRAG-ANYWHERE drop overlay. While a file is dragged anywhere over the page we show967# the #dropzone overlay ("Drop any file in the workspace"); it hides on drop OR when the drag leaves the page /968# is cancelled. On drop we forward the dropped file into the (visually hidden) gr.File's <input type=file> and969# fire a 'change' event, so Gradio runs the SAME attach_file handler (save into sandbox + append /workspace/<name>970# to the input). A dragenter/dragleave DEPTH counter keeps child elements from flickering the overlay. elem_id971# targets (#dropzone, #dropfile) are stable per the Gradio custom-CSS/JS guidance.972DROP_JS = """973() => {974  const z = document.getElementById('dropzone'); if (!z) return;975  let depth = 0;976  const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');977  const show = () => z.classList.add('drag-active');978  const hide = () => { depth = 0; z.classList.remove('drag-active'); };979  // forward the dropped file into Gradio's hidden file input so its .upload/attach_file wiring fires.980  const forward = (files) => {981    if (!files || !files.length) return;982    const input = document.querySelector('#dropfile input[type=file]');983    if (!input) return;984    try { input.files = files; } catch (err) { /* some browsers disallow assigning .files; ignore */ }985    input.dispatchEvent(new Event('change', { bubbles: true }));986  };987  window.addEventListener('dragenter', (e) => { if (hasFiles(e)) { depth++; show(); } });988  window.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); });   // allow drop anywhere989  window.addEventListener('dragleave', () => { depth = Math.max(0, depth - 1); if (depth === 0) hide(); });990  // drag cancelled / pointer left the window entirely -> hide.991  window.addEventListener('dragend', hide);992  document.addEventListener('mouseleave', () => { if (z.classList.contains('drag-active')) hide(); });993  window.addEventListener('drop', (e) => {994    e.preventDefault();995    hide();996    if (hasFiles(e)) forward(e.dataTransfer.files);997  });998}999"""1000 1001# Workbench chrome (computed once at startup). No top status strip (it duplicated the footer); instead the1002# empty-thread PLACEHOLDER teaches the write->run->verify value prop, and ONE consolidated FOOTER carries all1003# the runtime context (model/quant/runtime/tier + live web badge) plus the model card + the otherwise-invisible1004# MCP endpoint - each fact stated exactly once. All load-bearing info, no filler.1005WELCOME = (1006    "### &#9613; I write, run, and verify code, on a free CPU\n\n"1007    "Give me a coding task. I reason in `<think>`, then use **bash / write / read / edit** to build it, "1008    "run it in a sandbox, read the output, debug, and show the result inline.\n\n"1009    "&#128202; charts &amp; images  &middot;  &#128433;&#65039; little web apps  &middot;  &#129518; compute &amp; web look-ups"1010)1011_MODEL_REPO = os.environ.get("MODEL_REPO", "Luminia/MiniCPM5-1B-Agent-GGUF")1012FOOTER_HTML = (1013    '<div id="appfooter">'1014    '<span class="ft-mark">&#9613;</span>'1015    # "Q8_0 GGUF" IS the model-card link (no separate "model card" item; name + web badge dropped as redundant)1016    f'<a href="https://huggingface.co/{_MODEL_REPO}" target="_blank" rel="noopener" title="model card">Q8_0 GGUF &#8599;</a>'1017    '<span class="ft-sep">&middot;</span><span>CPU ~4 min/turn</span>'1018    '<span class="ft-sep">&middot;</span>'1019    '<span>MCP <code>run_coding_task</code> at <code>/gradio_api/mcp/</code></span>'1020    '</div>')1021 1022# Gradio 6: app-level theme/css/js live on demo.launch(...), NOT on gr.Blocks(...). Only `title` stays here.1023with gr.Blocks(title="MiniCPM5-1B-Agent") as demo:1024    # No hero banner and no top status strip (it duplicated the footer) - the chat opens at the top; all1025    # runtime context lives once in the footer.1026    sess = gr.State(_new_session)               # FRESH per-browser-session state (own sandbox, own chat)1027    # Drag-drop overlay affordance (ChatGPT-style): a full-app dashed drop zone that only appears while a1028    # file is being dragged (toggled by DROP_JS). Always in the DOM but visually inert until .drag-active.1029    gr.HTML('<div id="dropzone"><div class="dz-inner">&#128206; Drop any file in the workspace</div></div>',1030            elem_id="dropzone-host")1031    # Gradio 6: show_copy_button -> buttons=["copy"]; allow_tags pinned to False explicitly (its default1032    # flips to True in 6.x, which would change how the rendered <details>/<div> trajectory HTML is handled1033    # vs what this app was built and tested against - keep the prior, tested behavior). `placeholder` shows1034    # the WELCOME empty-state only while the thread is empty (auto-hides on the first real message).1035    # sanitize_html=False is REQUIRED: in Gradio 6.x the Chatbot's default HTML sanitizer STRIPS the1036    # srcdoc attribute off our live-preview <iframe> (and force-sets sandbox="allow-scripts"), so a produced1037    # web page would render as a BLANK white box. We don't rely on the sanitizer for safety: all chat content1038    # is app-generated, model-emitted text is html.escape()'d in _render, and the iframe is sandboxed (opaque1039    # origin, no allow-same-origin) with an html.escape'd srcdoc. allow_tags=False stays (custom-tag policy).1040    chat = gr.Chatbot(height=360, show_label=False, render_markdown=True,1041                      elem_id="chat", buttons=["copy"], allow_tags=False, sanitize_html=False,1042                      avatar_images=None, placeholder=WELCOME)1043    with gr.Row(elem_id="composer"):1044        inp = gr.Textbox(show_label=False, lines=1, max_lines=6,1045                         placeholder="Ask MiniCPM5-1B-Agent CPU, to run some code • Upload files here → workspace.",1046                         scale=6, autofocus=True, container=False, elem_id="prompt")1047        # Just [Send]. The Chatbot's built-in TRASH icon (top-right) is the single session control - it clears1048        # the chat AND (via chat.clear below) resets the session + aborts any in-flight run. (The old separate1049        # "🔄 New" button was redundant clutter; folded into the trash icon.)1050        with gr.Column(scale=1, min_width=92):1051            btn = gr.Button("Send", variant="primary", elem_id="send", scale=1)1052    # Hidden attach control (the REAL upload target): its underlying <input type=file> + .upload wiring stay1053    # intact, but the widget is VISUALLY HIDDEN (CSS #dropfile{display:none}). The affordance is dragging a1054    # file ANYWHERE over the page -> DROP_JS forwards the dropped file into this input's .files and fires a1055    # 'change' event, so Gradio saves it into the session sandbox and appends /workspace/<name> to the input.1056    drop = gr.File(label="attach", file_count="single", elem_id="dropfile", height=64)1057    attach_status = gr.Markdown("", elem_id="attachstatus")1058    # All 3 examples visible; the flex-wrap CSS lets them wrap to a 2nd row (no funny single-line truncation).1059    gr.Examples(EXAMPLES, inputs=inp, elem_id="examples", label="")1060    # One ultra-quiet footer line: model card link + the (otherwise invisible) MCP endpoint - real signal.1061    gr.HTML(FOOTER_HTML, elem_id="footer-host")1062    _outs = [chat, sess, inp]1063    # Gradio 6: event listeners use api_visibility=... (the old show_api=False). These UI handlers are stateful1064    # generators (gr.State / gr.update / component-valued bubbles) that make no sense as MCP tools, so hide them1065    # from API docs + the MCP server ("undocumented" == old show_api=False; the single MCP tool is the clean1066    # run_coding_task endpoint wired via gr.api below).1067    # Drive the displayed chat from gr.State (sess), NOT from the Chatbot component: inputs never include1068    # `chat`, so component-valued media bubbles never round-trip through the Chatbot's preprocess.1069    run_evt = btn.click(run, inputs=[inp, sess], outputs=_outs, concurrency_limit=1,1070                        api_visibility="undocumented")1071    sub_evt = inp.submit(run, inputs=[inp, sess], outputs=_outs, concurrency_limit=1,1072                         api_visibility="undocumented")1073    # Clear the transient "added /workspace/x ... before sending" attach hint once the turn finishes, so it1074    # doesn't linger (saying "before sending") under an empty composer into the next turn. Chained as a1075    # separate .then on the SAME event objects (run_evt/sub_evt stay the run generators, so cancels= still1076    # targets them); attach_status is NOT in _outs, so the run generator's 3-tuple yields are untouched.1077    _clear_attach = lambda: gr.update(value="")1078    run_evt.then(_clear_attach, outputs=[attach_status], api_visibility="undocumented")1079    sub_evt.then(_clear_attach, outputs=[attach_status], api_visibility="undocumented")1080    # The Chatbot's built-in TRASH icon (top-right) is the single session control: clicking it fires chat.clear1081    # -> reset_session (fresh session: new sandbox/history/cleared chat) AND cancels= aborts any in-flight run at1082    # its next yield. reset_session flips the cancel flag first so the live indicator shows 'cancel Ns...'.1083    # (gr.Chatbot.clear fires on the trash-icon click - verified in the Gradio 6 docs.)1084    chat.clear(reset_session, inputs=[sess], outputs=_outs, cancels=[run_evt, sub_evt],1085               api_visibility="undocumented")1086    # Drag-drop / attach: save into the session sandbox + append the path; clear the file widget after.1087    drop.upload(attach_file, inputs=[drop, inp, sess], outputs=[inp, sess, attach_status],1088                api_visibility="undocumented").then(1089        lambda: None, outputs=[drop], api_visibility="undocumented")1090    # MCP server: expose the agent as ONE clean, typed, documented tool an external MCP client can call to run a1091    # coding task. gr.api registers a pure-logic endpoint (fully typed signature -> MCP schema) with no UI, so it1092    # does not disturb the Gradio interface above. demo.launch(mcp_server=True) below turns it into an MCP tool.1093    gr.api(run_coding_task, api_name="run_coding_task")1094    # Session isolation: clean THIS session's sandbox when the browser disconnects (never the shared server).1095    demo.unload(on_unload)1096 1097# Free 2-vCPU CPU Space with ONE shared llama-server: serialize model use so concurrent users queue rather1098# than thrash the single server. default_concurrency_limit=1 (one inference at a time), bounded queue.1099demo.queue(default_concurrency_limit=1, max_size=16)1100 1101if __name__ == "__main__":1102    # Gradio 6: app-level theme/css/js moved here from gr.Blocks(...). mcp_server=True publishes the MCP server1103    # at /gradio_api/mcp/ (the run_coding_task tool). ssr_mode=False so the GoogleFont theme renders correctly1104    # (Gradio SSR drops construction-time fonts).1105    demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False,1106                theme=THEME, css=CSS, js=DROP_JS, mcp_server=True)1107