import spaces # MUST precede torch — monkey-patches torch.cuda for ZeroGPU import json import re import sys from pathlib import Path import torch import gradio as gr from huggingface_hub import snapshot_download, hf_hub_download from transformers import AutoModelForCausalLM, AutoTokenizer # ── Environment source (downloaded once at startup; CPU-only, fine at module scope) ── ENV_REPO = "Nanthasit/hermes-tool-use-rl-env" env_dir = Path(snapshot_download(ENV_REPO, repo_type="dataset")) sys.path.insert(0, str(env_dir)) sys.path.insert(0, str(env_dir / "server")) from models import HermesToolAction # noqa: E402 from tasks import TASKS_BY_ID # noqa: E402 from hermes_tool_env import HermesToolEnvironment # noqa: E402 TASK_IDS = sorted(TASKS_BY_ID) MAX_STEPS = 12 SYSTEM = ("You are a coding agent working in a sandboxed workspace. Inspect the " "files, make the change the task asks for, then call submit to have it " "graded. Call exactly one tool per turn.") TOOLS = [ {"type": "function", "function": {"name": "terminal", "description": "Run a shell command in the task workspace; returns combined stdout/stderr.", "parameters": {"type": "object", "properties": { "command": {"type": "string", "description": "Shell command to run."}}, "required": ["command"]}}}, {"type": "function", "function": {"name": "read_file", "description": "Read a file from the task workspace.", "parameters": {"type": "object", "properties": { "path": {"type": "string", "description": "Relative path of the file to read."}}, "required": ["path"]}}}, {"type": "function", "function": {"name": "write_file", "description": "Write or overwrite a file in the task workspace.", "parameters": {"type": "object", "properties": { "path": {"type": "string", "description": "Relative path of the file to write."}, "content": {"type": "string", "description": "Full content to write to the file."}}, "required": ["path", "content"]}}}, {"type": "function", "function": {"name": "patch", "description": "Find-and-replace a unique substring in a file.", "parameters": {"type": "object", "properties": { "path": {"type": "string", "description": "Relative path of the file to patch."}, "old_string": {"type": "string", "description": "Exact unique substring to replace."}, "new_string": {"type": "string", "description": "Replacement text."}}, "required": ["path", "old_string", "new_string"]}}}, {"type": "function", "function": {"name": "submit", "description": "End the episode and grade the task. Call this only when you believe the task is solved.", "parameters": {"type": "object", "properties": {}}}}, ] _TC = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL) # ── Hand-rolled renderer (bench-v2 format; matches the original -tools models) ── def _text(c): return "" if c is None else (c if isinstance(c, str) else json.dumps(c, ensure_ascii=False)) def _tools_block(tools): if not tools: return "" sigs = "\n".join(json.dumps(t, ensure_ascii=False) for t in tools) return ("\n\n# Tools\n\nYou may call one or more functions. Signatures are within " ":\n\n" + sigs + "\n\n\nFor each call return:\n" "\n{\"name\": , \"arguments\": }\n") def _render_msg(m, tools_sys): r = m.get("role") if r == "system": return "<|im_start|>system\n" + _text(m.get("content")) + _tools_block(tools_sys) + "<|im_end|>\n" if r == "user": return "<|im_start|>user\n" + _text(m.get("content")) + "<|im_end|>\n" if r == "tool": return "<|im_start|>user\n\n" + _text(m.get("content")) + "\n<|im_end|>\n" if r == "assistant": return "<|im_start|>assistant\n" + _text(m.get("content")) + "<|im_end|>\n" return "" def render_handrolled(messages): out = [] for i, m in enumerate(messages): out.append(_render_msg(m, TOOLS if (i == 0 and m.get("role") == "system") else None)) out.append("<|im_start|>assistant\n") return "".join(out) def parse_tool_call(text): m = _TC.search(text) if not m: return None try: return json.loads(m.group(1)) except Exception: return None def _peft_base_model(repo_id): try: cfg_path = hf_hub_download(repo_id, "adapter_config.json") except Exception: return None with open(cfg_path) as f: return json.load(f).get("base_model_name_or_path") # Cache loaded (model, tokenizer) per repo_id, populated inside @spaces.GPU where # the GPU is actually attached. Read-mostly; fine for a personal eval tool. _CACHE = {} def load_model(repo_id): if repo_id in _CACHE: return _CACHE[repo_id] base_id = _peft_base_model(repo_id) load_id = base_id or repo_id try: tok = AutoTokenizer.from_pretrained(repo_id) except Exception: tok = AutoTokenizer.from_pretrained(base_id) if tok.pad_token is None: tok.pad_token = tok.eos_token m = AutoModelForCausalLM.from_pretrained(load_id, torch_dtype=torch.bfloat16, device_map="cuda") if base_id: from peft import PeftModel m = PeftModel.from_pretrained(m, repo_id) m.eval() _CACHE[repo_id] = (m, tok) return m, tok def step_fn(model, tok, messages, render_mode): if render_mode == "native": enc = tok.apply_chat_template(messages, tools=TOOLS, add_generation_prompt=True, return_dict=True, return_tensors="pt").to(model.device) input_len = enc["input_ids"].shape[-1] else: prompt = render_handrolled(messages) enc = tok(prompt, return_tensors="pt", add_special_tokens=False).to(model.device) input_len = enc["input_ids"].shape[-1] with torch.no_grad(): out = model.generate(**enc, max_new_tokens=512, do_sample=False, pad_token_id=tok.pad_token_id) return tok.decode(out[0, input_len:], skip_special_tokens=True) def run_episode(model, tok, task_id, render_mode): env = HermesToolEnvironment() env.reset() obs = env.step(HermesToolAction(tool="select_task", task_id=task_id)) messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": obs.result}] lines = [f"[task] {obs.result[:200]}"] for _ in range(MAX_STEPS): completion = step_fn(model, tok, messages, render_mode) messages.append({"role": "assistant", "content": completion}) tc = parse_tool_call(completion) if tc is None: lines.append(f"[model] (no tool call) {completion[:120]}") messages.append({"role": "tool", "content": "No found; you must call a tool."}) continue lines.append(f"[model] {tc.get('name')}({json.dumps(tc.get('arguments', {}))[:100]})") try: obs = env.step(HermesToolAction(tool=tc["name"], **(tc.get("arguments") or {}))) except Exception as e: messages.append({"role": "tool", "content": f"Invalid tool call: {e}"}) lines.append(f"[env] invalid: {e}") continue messages.append({"role": "tool", "content": obs.result}) lines.append(f"[env] {obs.result[:100]}") if obs.done: return float(obs.reward or 0.0), lines return 0.0, lines @spaces.GPU(duration=300) def evaluate(repo_id, render_mode, show_transcript): repo_id = repo_id.strip() if not repo_id: return "Enter a model repo id.", "" model, tok = load_model(repo_id) rows, transcripts, passed = [], [], 0 for task_id in TASK_IDS: reward, lines = run_episode(model, tok, task_id, render_mode) passed += int(reward >= 1.0) mark = "✅" if reward >= 1.0 else "❌" rows.append(f"| {task_id} | {mark} {reward:.0f} |") if show_transcript: transcripts.append(f"### {task_id} — reward {reward:.0f}\n```\n" + "\n".join(lines) + "\n```") summary = (f"## `{repo_id}` — **{passed}/{len(TASK_IDS)}** ({render_mode} render)\n\n" "| task | reward |\n|---|---|\n" + "\n".join(rows)) return summary, ("\n\n".join(transcripts) if show_transcript else "") with gr.Blocks(title="SakThai Agentic Eval") as demo: gr.Markdown( "# 🧪 SakThai Agentic Eval (free, ZeroGPU)\n" "Runs the 6-task [hermes-tool-use-rl-env](https://huggingface.co/datasets/Nanthasit/hermes-tool-use-rl-env) " "agentic coding benchmark against any SakThai model — binary pass/fail per task, graded by the " "environment's real checker. No HF Jobs / paid compute needed." ) with gr.Row(): repo = gr.Textbox(value="Nanthasit/sakthai-context-7b-tools", label="Model repo id", scale=3) render = gr.Dropdown(choices=["native", "handrolled"], value="native", label="Prompt render", info="native = apply_chat_template (use for SFT/GRPO-trained models); handrolled = bench-v2 renderer", scale=2) show_tr = gr.Checkbox(value=True, label="Show per-task transcript") btn = gr.Button("Run agentic eval (6 tasks)", variant="primary") summary = gr.Markdown() transcript = gr.Markdown() btn.click(evaluate, inputs=[repo, render, show_tr], outputs=[summary, transcript]) gr.Examples( examples=[ ["Nanthasit/sakthai-context-7b-tools", "native", True], ["Nanthasit/sakthai-context-0.5b-tools-sft", "native", True], ["Nanthasit/sakthai-context-0.5b-tools", "native", True], ], inputs=[repo, render, show_tr], ) demo.launch()