"""MiniCPM5-1B-Agent - agentic coding demo Space. Wires the SAME backend agent loop used by the eval (backend/agent.py: think -> tool-call -> run -> read output -> debug -> verify) around the shipped Q8_0 GGUF, served by llama-server. The user gives a coding task; the agent writes/runs/fixes code in a sandbox and we render the full write->run->verify trajectory + final answer + produced files INLINE in the chat thread. Runtime layout (Docker /app): app.py ยท backend/agent.py ยท data/schema.py ยท tokenizer/ ยท model.gguf Env (set by Dockerfile, overridable for local test): CODEAGENT_PROJ -> dir holding data/schema.py (default: this file's dir) CODEAGENT_LLAMA_BIN -> llama-server binary (default: "llama-server") CODEAGENT_GGUF -> path to the Q8_0 GGUF (default: /app/model.gguf) CODEAGENT_CTX -> llama-server context (default 8192) CODEAGENT_THREADS -> llama-server threads (default 2 - free 2-vCPU tier) """ import os, sys, glob, html, atexit, shutil, tempfile, time, threading HERE = os.path.dirname(os.path.abspath(__file__)) # agent.py reads these; set BEFORE importing it so PROJ/LLAMA_BIN resolve to the Space layout. os.environ.setdefault("CODEAGENT_PROJ", HERE) # -> data/schema.py is at HERE/data os.environ.setdefault("CODEAGENT_LLAMA_BIN", "llama-server") sys.path.insert(0, HERE) # for `import backend.agent` sys.path.insert(0, os.path.join(HERE, "backend")) # agent.py also imported flat import gradio as gr from transformers import AutoTokenizer import backend.agent as agent GGUF = os.environ.get("CODEAGENT_GGUF", "/app/model.gguf") CTX = int(os.environ.get("CODEAGENT_CTX", "8192")) THREADS = int(os.environ.get("CODEAGENT_THREADS", "2")) MAX_ITERS = int(os.environ.get("CODEAGENT_MAX_ITERS", "8")) # PER-TURN ACTION budget (phase B). The runaway is bounded SEPARATELY (CODEAGENT_THINK_CAP, default # 1024), so this only sizes the model's ACTION (a tool call / a file write / the final answer). Balance: at # ~8 tok/s on the free 2-vCPU tier, a full-budget action costs ~budget/8 seconds, so an over-large cap makes # a verbose turn take 20+ min. 6144 fits a clean, compact file write (the prompt now tells the model to WRITE # the file, not paste it) while keeping turns reasonable; and since the Chatbot no longer strips iframe srcdoc # (sanitize_html=False), a page that DOES exceed the cap now renders PARTIALLY via the salvage path instead of # as a blank box - so truncation degrades gracefully rather than failing hard. Override via CODEAGENT_NPRED. NPRED = int(os.environ.get("CODEAGENT_NPRED", "6144")) # Private deployment: if the GGUF isn't already on disk, pull it from the (PRIVATE) model repo using the # HF_TOKEN Space secret. (Local/public runs instead point CODEAGENT_GGUF at an existing file.) if not os.path.exists(GGUF) and os.environ.get("MODEL_REPO"): from huggingface_hub import hf_hub_download _repo, _fn = os.environ["MODEL_REPO"], os.environ["MODEL_FILE"] print(f"[app] downloading {_repo}/{_fn} (private; HF_TOKEN={'set' if os.environ.get('HF_TOKEN') else 'MISSING'}) ...", flush=True) GGUF = hf_hub_download(repo_id=_repo, filename=_fn, token=os.environ.get("HF_TOKEN"), local_dir="/app") # The tokenizer lives WITH the model (in the GGUF repo under tokenizer/) - one source of truth, never bundled # in the Space. Pull it from MODEL_REPO with the same HF_TOKEN secret used for the GGUF. _tokdir = os.path.join(HERE, "tokenizer") if not os.path.exists(os.path.join(_tokdir, "tokenizer.json")) and os.environ.get("MODEL_REPO"): from huggingface_hub import snapshot_download print(f"[app] downloading tokenizer from {os.environ['MODEL_REPO']}/tokenizer ...", flush=True) snapshot_download(repo_id=os.environ["MODEL_REPO"], allow_patterns="tokenizer/*", token=os.environ.get("HF_TOKEN"), local_dir=HERE) print(f"[app] loading tokenizer from {_tokdir} ...", flush=True) TOK = AutoTokenizer.from_pretrained(_tokdir, trust_remote_code=True) # SECURITY: the GGUF + tokenizer are now downloaded, so HF_TOKEN is no longer needed. DROP it from the # process env BEFORE any per-turn Sandbox is created - the bash tool runs model-emitted shell with the full # inherited env, so leaving the private-repo secret in os.environ would let a task `echo $HF_TOKEN` exfil it. os.environ.pop("HF_TOKEN", None) os.environ.pop("HUGGING_FACE_HUB_TOKEN", None) print(f"[app] starting llama-server on {GGUF} (ctx={CTX}, threads={THREADS}, ngl=0) ...", flush=True) SERVER = agent.LlamaServer(GGUF, port=8099, ctx=CTX, threads=THREADS, ngl=0) SERVER.__enter__() # block until /health ok; kept alive for the app lifetime atexit.register(lambda: SERVER.__exit__(None, None, None)) print("[app] llama-server healthy - ready.", flush=True) # Web tools: AUTO-DETECT at startup (no manual flag). If the internet is reachable, turn on web_search/web_fetch. WEB_ON = agent.web_available() if WEB_ON: agent.enable_web() print("[app] internet reachable -> web tools ON (web_search + web_fetch incl. Reddit via arctic_shift)", flush=True) else: print("[app] no internet -> web tools OFF (fully local / Off-the-Grid)", flush=True) # Best-effort: make /workspace exist + writable. The 1B (Claude-Code habit) sometimes savefig's/writes to an # ABSOLUTE '/workspace/...' path INSIDE its script - the bash-path rewrite can't reach in-code paths, so without # this the write would fail (or land at an unscanned root) and the artifact would never render. With /workspace # present, those writes succeed and _extra_media() scans it (this-turn-only). Harmless if perms deny it (the # model then uses relative paths in the per-session sandbox, which _media_messages already scans). try: os.makedirs("/workspace", exist_ok=True) except Exception: pass # Every example is a NATURAL request (how a real user actually asks - no backend hand-holding; the # save/run/verify conventions live in the SYSTEM PROMPT, not here) and was VALIDATED on the shipped dpo_v3 # GGUF through THIS agent loop. The first two PASS on the fine-tune but FAIL on the un-tuned base, so they # showcase what the training added. The 1B handles a chart, an HTML mini-app, and a computed answer, but ONLY # when the prompt is concrete (name the .html file / "hard-coded, no internet"); an open-ended "make a web # page" reliably gets mis-read as a Python CLI or triggers a pointless web_search. (Charts/GIFs/games need # step-by-step spoon-feeding a 1B can't infer from a natural prompt - left out.) # Each example exercises a DISTINCT capability so clicking through them shows the range: a matplotlib # chart -> PNG, an interactive HTML mini-app, and a compute + web look-up. EXAMPLES = [ # python chart -> image shown inline in the chat (phrased as "Python script ... saves a PNG ... run it" so the 1B # writes a .py + runs it, instead of dumping code into the .png; full ctx + the save-plots system rule help) "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.", # interactive HTML mini-app -> renders live inline. EXPLICIT phrasing ("Write an HTML page .html ... # hard-coded ... no internet") so the 1B writes a single self-contained .html FILE instead of mis-reading # "web page" as a Python CLI or web_search'ing (the open-ended "make a little web page" prompt reliably # botched both). Mirrors the write-a-file pattern the 1B nails. "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.", # practical compute + WEB look-up (when online): "look up this year's avg rent" implicitly needs web_search "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.", ] import re as _re # Display-only cruft: control tokens AND leaked tool-call XML. When the 1B emits a malformed/partial tool call, # raw //CDATA fragments can spill into the rendered bubble (e.g. "...]]>"); # strip those tags from the SHOWN text only. This does NOT touch the agent loop's real XML parsing in agent.py. _CRUFT = _re.compile( r"|<\|im_(start|end)\|>|<\|endoftext\|>" r"|]*>|]*>|") def _clean(t): """Display-only: strip special/control tokens AND leaked tool-call XML that can spill into rendered text (no effect on the agent loop's parsing).""" return _CRUFT.sub("", t or "").strip() def _fmt_tool_call(c): # Tool calls are stored in the canonical NESTED shape {"type":"function","function":{name,arguments}} # (agent.py run_agent). Read the nested function.name/function.arguments directly; tolerate a flat dict. fn = c.get("function") or c name = fn.get("name", "?") arguments = fn.get("arguments") or {} arg_preview = ", ".join(f"{k}={str(v)[:60]!r}" for k, v in list(arguments.items())[:3]) return f"๐Ÿ”ง **`{name}`**({arg_preview})" def _tool_disclosure(body, max_chars=1500): """One collapsible
block for a tool's (possibly long) output - de-dupes the two identical blocks the renderer used for role:tool and the legacy user message.""" return f"
๐Ÿ“ค tool output\n\n```\n{str(body)[:max_chars]}\n```\n
" def _assistant_parts(m): """Read an assistant turn's display parts from the CANONICAL stored fields (reasoning_content, tool_calls, content) rather than re-parsing content. On a tool-calling turn the loop already split /tool-calls out and set content="", so re-parsing would recover nothing. Returns (reasoning, tool_calls, final).""" return (_clean(m.get("reasoning_content") or ""), m.get("tool_calls") or [], _clean(m.get("content") or "")) def _render(messages, max_chars=1500): """Render the write->run->verify trajectory as readable markdown (assistant turns only - the user's task is already shown as their own chat bubble). Renders the CANONICAL message fields the agent loop stored (reasoning_content, tool_calls, content) directly - it does NOT re-parse content, because on a tool-calling turn the loop already split /tool-calls out and set content="", so re-parsing would recover nothing and the turn would render blank.""" out = [] for m in messages: role = m.get("role") content = m.get("content") or "" if role in ("system", "user"): if role == "user" and content.startswith(""): body = content.replace("", "").replace("", "").strip() out.append(_tool_disclosure(body, max_chars)) continue if role == "tool": out.append(_tool_disclosure(content, max_chars)) continue # assistant: render the canonical fields the loop stored (reasoning_content, tool_calls, content). reasoning, tool_calls, final = _assistant_parts(m) if reasoning: out.append(f"
๐Ÿ’ญ thinking\n\n{html.escape(reasoning[:max_chars])}\n
") for c in tool_calls: out.append(_fmt_tool_call(c)) if final: # The final answer renders as PLAIN chat-bubble text (it is already in the assistant bubble) - no # "๐Ÿค–"-narration prefix or bold framing that made it read like internal monologue / blur with the # user's own turn. Reasoning + tool output stay in their collapsible
blocks above. out.append(html.escape(final[:max_chars])) return "\n\n".join(out) IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp") SVG_EXT = (".svg",) # rendered inline as HTML (gr.Image rasterizers don't reliably handle SVG) VID_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv") def _copy_out(p, outdir): """Copy an artifact OUT of the (ephemeral, per-session) workspace into a stable temp dir Gradio can serve, and return the destination path.""" dst = os.path.join(outdir, os.path.basename(p)) shutil.copy2(p, dst) return dst def _media_bubble(content): """Wrap a media component (gr.Image/gr.Video/gr.HTML) as one assistant chat message so it renders INLINE in the thread (ChatGPT-style), not in a separate box.""" return {"role": "assistant", "content": content} def _html_iframe(body): """A live, SANDBOXED iframe for an agent-produced .html page (opaque origin: no allow-same-origin, so the page's JS/forms/buttons run but stay contained), wrapped in a tiny browser-chrome "live preview" frame so the running mini-app reads as a real artifact, not a floating white rectangle. srcdoc is html.escape'd (quote=True default) so a " in the page can't break out of the attribute. Sizing/border come from CSS (.lp-frame / .lp-frame iframe).""" iframe = (f'') return (f'
' f'
' f'live preview
{iframe}
') _HTML_START = _re.compile(r"]", _re.IGNORECASE) def _salvage_html(text): """The 1B often PASTES a full HTML page into its prose answer instead of write()-ing a file (so no .html file exists to iframe). Extract the document so the demo still renders it LIVE - and tolerate a page that got CUT OFF mid-tag (no ): render what we have (browsers are lenient) and flag it so the chat can say WHY it looks broken. Returns (html, warning) - warning is None when the page is complete.""" if not isinstance(text, str): return None, None m = _HTML_START.search(text) if not m: return None, None doc = text[m.start():] end = doc.lower().rfind("") if end != -1: # complete page: trim trailing prose after return doc[:end + 7].strip(), None return doc.strip(), ("the model pasted the page into its reply instead of saving it to a file, then hit the " "length limit and got cut off (no ``). Rendering the partial page; ask it to " "*save the page to an .html file with the write tool* so the whole thing renders.") def _valid_image(p): """True only if p starts with a real raster-image magic number. Guards against the 1B 'writing' a chart.png as TEXT (a bogus file that would render as an empty/0x0 bubble) - such a file fails this check and is skipped.""" try: with open(p, "rb") as f: head = f.read(12) except OSError: return False return (head.startswith(b"\x89PNG") or head.startswith(b"\xff\xd8\xff") or head[:4] == b"GIF8" or head[:2] == b"BM" or (head[:4] == b"RIFF" and head[8:12] == b"WEBP")) def _render_media_file(p, outdir): """Map ONE produced file to an inline chat bubble by extension, or None if it's not media. png/jpg/gif/... -> gr.Image; .svg / .html -> a sandboxed iframe (SVG is active content, so never inline it raw into the parent DOM); video -> gr.Video. Shared by the sandbox scan AND the extra-dir scan.""" ext = os.path.splitext(p)[1].lower() try: if ext in IMG_EXT: return _media_bubble(gr.Image(_copy_out(p, outdir))) if _valid_image(p) else None if ext in VID_EXT: return _media_bubble(gr.Video(_copy_out(p, outdir))) if ext in SVG_EXT or ext in (".html", ".htm"): return _media_bubble(gr.HTML(_html_iframe(open(p, encoding="utf-8", errors="replace").read()))) except Exception: return None return None def _extra_media(dirs, since_ts, exclude_dir): """Catch media the model wrote to an ABSOLUTE path OUTSIDE its per-session sandbox - e.g. a savefig to '/workspace/chart.png' INSIDE the script (a Claude-Code habit the bash-path rewrite can't reach, so the PNG lands at /workspace, not the scanned sandbox dir). Scan the given dirs for media modified THIS turn (mtime > since_ts) so a fresh session never re-shows a prior session's files (concurrency_limit=1 => one run at a time, race-free). Dirs that don't exist (e.g. /workspace on Windows) are skipped; the sandbox is excluded.""" msgs = [] outdir = tempfile.mkdtemp(prefix="codeagent_out_") exclude = os.path.abspath(exclude_dir) if exclude_dir else None for d in dirs: if not (d and os.path.isdir(d)) or (exclude and os.path.abspath(d) == exclude): continue for p in sorted(glob.glob(os.path.join(d, "**", "*"), recursive=True))[:40]: if not os.path.isfile(p): continue try: if os.path.getmtime(p) <= since_ts: continue except OSError: continue b = _render_media_file(p, outdir) if b is not None: msgs.append(b) return msgs def _media_messages(ws, since=0.0): """Scan the persistent workspace and turn whatever the agent PRODUCED/CHANGED THIS TURN into inline chat messages (gr.Chatbot type='messages'): images/gifs/charts (png/jpg/jpeg/gif/bmp/webp) -> gr.Image, .svg -> inline gr.HTML, videos -> gr.Video, .html -> a live sandboxed iframe via gr.HTML. Non-media files are ignored. `since` is an mtime watermark: only files modified after it are shown, so prior turns' artifacts are NOT re-rendered. Returns (msgs, new_watermark). Nothing is hard-coded to a task.""" msgs, watermark = [], since if not (ws and os.path.isdir(ws)): return msgs, watermark outdir = tempfile.mkdtemp(prefix="codeagent_out_") # copy out so Gradio can serve a stable path for p in sorted(glob.glob(os.path.join(ws, "**", "*"), recursive=True))[:40]: if not os.path.isfile(p): continue try: mt = os.path.getmtime(p) except OSError: continue if mt > watermark: watermark = mt if mt <= since: # produced/changed in an EARLIER turn -> skip continue b = _render_media_file(p, outdir) # png/jpg/.. -> Image; svg/html -> sandboxed iframe; video -> Video if b is not None: msgs.append(b) return msgs, watermark def _new_session(): """A fresh per-browser-session state. The DISPLAYED chat lives in `chat` here (gr.State) so the Chatbot is purely an OUTPUT - component-valued media bubbles never round-trip through the Chatbot's preprocess. `mtime` is the media watermark; `sandbox`/`history` persist the agent workspace + convo; `token` is the Gradio session_hash so demo.unload can clean THIS session's sandbox on leave.""" return {"sandbox": None, "history": None, "chat": [], "mtime": 0.0, "token": None} # When the user clicks "๐Ÿ”„ New" mid-generation, Gradio's cancels= stops THIS run generator (frees the UI for # the fresh session). reset_session ALSO flips this per-token flag so, if the generator yields once more before # cancellation lands, its live indicator switches from "Ns..." to "cancel Ns..." - the user sees New registered # and the old turn aborting. (run_agent has no cooperative abort hook, so the background worker finishes the # current turn on its own; its result is simply discarded because the session was reset.) _CANCEL_FLAGS = {} # session token -> threading.Event (set => the in-flight run for this session is cancelling) def _cancel_event(sess): tok = (sess or {}).get("token") if not tok: return None ev = _CANCEL_FLAGS.get(tok) if ev is None: ev = _CANCEL_FLAGS[tok] = threading.Event() return ev _WORD_RE = _re.compile(r"[A-Za-z]{3,}") # an alphabetic word of length >= 3 def _is_gibberish(task): """CONSERVATIVE frontend guard ONLY (does NOT alter the model's act-not-ask behavior on real tasks): treat a task as gibberish if it is too short or has no real word - e.g. an accidental 'dd'. Anything with a >=3-letter alphabetic word (a genuine short request like 'plot sin x') is NOT gibberish and runs normally.""" t = (task or "").strip() if len(t.replace(" ", "")) <= 3: # <= 3 non-space chars (e.g. "dd", "ok", "?!") return True if len(set(t.replace(" ", ""))) <= 1: # a single repeated char (e.g. "aaaa") return True if not _WORD_RE.search(t): # no alphabetic word of length >= 3 anywhere return True return False def run(task, sess, request: gr.Request = None): """One turn (STREAMING generator): append the user's task + the agent's trajectory and any produced media INLINE in the chat. The displayed chat is driven from gr.State (sess['chat']); the Chatbot itself is never read as input. The blocking agent loop runs in a worker thread while we yield a live elapsed-time indicator ("Ns...") so the UI stays responsive AND so Gradio has yield points to CANCEL on (the "๐Ÿ”„ New" button uses cancels= to abort this generator).""" sess = sess or _new_session() if request is not None and not sess.get("token"): sess["token"] = getattr(request, "session_hash", None) task = (task or "").strip() chat = list(sess.get("chat") or []) if not task: yield chat, sess, "" return # Frontend gibberish guard: don't burn the agent loop on an accidental "dd". Keep the typed input so the # user can fix it; just nudge (transient - not persisted to sess['chat']) for a real task. (Conservative - # real short requests still run; see _is_gibberish.) if _is_gibberish(task): nudge = chat + [{"role": "assistant", "content": "What would you like me to build or run? Describe a coding task."}] yield nudge, sess, gr.update() return # html.escape the user's text for DISPLAY only (the agent still gets the raw `task`): with sanitize_html # off, an unescaped angle-bracket the user types (e.g. "

" in a coding question, or a pasted snippet) # would otherwise be interpreted as live HTML in the bubble - vanishing from view (or, for