#!/usr/bin/env python
"""Score a checkpoint (or the untouched base) on held-out agent tool-calling.
python train/eval_tools.py --base Qwen/Qwen3.5-2B # baseline
python train/eval_tools.py --base Qwen/Qwen3.5-2B --adapter runs/agent-2b/adapter
Reporting the base and the tuned model through the identical path is the whole
point — a tool-calling score means nothing without the lift it was measured
against.
Metrics, per task type and overall:
tool_name_acc did it call the tool the target called
args_json_valid did the emitted arguments parse at all
args_exact every argument name and value matches
args_f1 per-field F1, so a near-miss scores better than a miss
refusal_acc on `refuse` rows: correctly declined to call anything
price_acc on `price` rows: picked the cheapest tool that fits
Qwen3.5 emits tool calls in an XML-ish form, not JSON:
value
so `parse_tool_calls` reads that first and falls back to the Qwen2.5-style
JSON block, letting the same harness score either family.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "train"))
from data_utils import normalize_row # noqa: E402
_XML_CALL = re.compile(r"]+)>(.*?)", re.S)
_XML_PARAM = re.compile(r"]+)>\n?(.*?)\n?", re.S)
_JSON_CALL = re.compile(r"\s*(\{.*?\})\s*", re.S)
def _coerce(text: str):
"""Parameters arrive as text; recover JSON values where they clearly are."""
s = text.strip()
if s and (s[0] in "{[" or s in ("true", "false", "null") or re.fullmatch(r"-?\d+(\.\d+)?", s)):
try:
return json.loads(s)
except json.JSONDecodeError:
return s
return s
def parse_tool_calls(text: str) -> tuple[list[dict], bool]:
"""Return (calls, well_formed). `calls` is [{name, arguments}]."""
calls: list[dict] = []
ok = True
for name, body in _XML_CALL.findall(text):
args = {k.strip(): _coerce(v) for k, v in _XML_PARAM.findall(body)}
calls.append({"name": name.strip(), "arguments": args})
if calls:
return calls, ok
for blob in _JSON_CALL.findall(text):
try:
obj = json.loads(blob)
args = obj.get("arguments", {})
if isinstance(args, str):
args = json.loads(args)
calls.append({"name": obj.get("name", ""), "arguments": args if isinstance(args, dict) else {}})
except json.JSONDecodeError:
ok = False
# A bare "" with nothing parseable is a malformed attempt, not a
# clean refusal — distinguish so refusal accuracy stays honest.
if not calls and "" in text:
ok = False
return calls, ok
def target_call(row: dict) -> dict | None:
for msg in row["messages"]:
if msg.get("role") == "assistant" and msg.get("tool_calls"):
fn = msg["tool_calls"][0].get("function", msg["tool_calls"][0])
args = fn.get("arguments")
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
return {"name": fn.get("name", ""), "arguments": args or {}}
return None
def _extractability_of(raw: dict, gold: dict | None) -> dict | None:
"""Per-argument extractability, from the row if the generator recorded it,
else recomputed from the user text. Returns None only when there is no gold
call to grade."""
if not gold:
return None
tagged = ((raw.get("meta") or {}).get("arg_extractable")
if isinstance(raw.get("meta"), dict) else None)
if isinstance(tagged, dict) and tagged:
return tagged
# Older rows predate the tag; recompute with the same rule so a corpus
# rebuild is not required to get an honest number.
user = next((m.get("content") or "" for m in raw.get("messages", [])
if m.get("role") == "user"), "")
return {k: _is_extractable(v, user) for k, v in (gold.get("arguments") or {}).items()}
def _score_extractable(pred: dict, gold: dict, ext: dict | None) -> float:
"""1.0 when every EXTRACTABLE gold value is reproduced exactly and every
SYNTHESISED gold key is present with a type-compatible value.
The asymmetry is the point: reproducing a value the user stated is a real
skill; reproducing one the dataset invented is impossible, so we ask only
that the model supply *something of the right shape* for that field.
"""
gold = gold or {}
if not gold:
return 1.0 if not pred else 0.0
ext = ext or {}
for k, gv in gold.items():
if k not in (pred or {}):
return 0.0
pv = pred[k]
if ext.get(k):
if not _values_equal(pv, gv):
return 0.0
else:
if not _type_compatible(pv, gv):
return 0.0
return 1.0
def _type_compatible(a, b) -> bool:
"""Same JSON shape. `5` vs `"5"` counts as compatible: an untyped schema
accepts both and we do not want to fail a model for a defensible choice."""
if isinstance(a, bool) or isinstance(b, bool):
return isinstance(a, bool) and isinstance(b, bool)
num = (int, float)
if isinstance(b, num):
if isinstance(a, num):
return True
return isinstance(a, str) and re.fullmatch(r"-?\d+(?:\.\d+)?", a.strip() or "") is not None
if isinstance(b, str):
return isinstance(a, (str, int, float))
if isinstance(b, list):
return isinstance(a, list)
if isinstance(b, dict):
return isinstance(a, dict)
return type(a) is type(b)
def _values_equal(a, b) -> bool:
if isinstance(a, (int, float)) and isinstance(b, (int, float)) \
and not isinstance(a, bool) and not isinstance(b, bool):
return abs(float(a) - float(b)) < 1e-9
return str(a).strip().casefold() == str(b).strip().casefold()
def _is_extractable(value, user_text: str) -> bool:
"""Mirror of swarm-train/verify/reward.py's rule, inlined so the eval has no
import dependency on the RL package. Kept deliberately identical."""
if value is None or isinstance(value, bool):
return False
s = str(value).strip()
if len(s) < 2:
return False
if s.casefold() in (user_text or "").casefold():
return True
if isinstance(value, (int, float)):
for tok in re.findall(r"-?\d+(?:\.\d+)?", user_text or ""):
try:
if abs(float(tok) - float(value)) < 1e-9:
return True
except ValueError:
continue
return False
def field_f1(pred: dict, gold: dict) -> float:
if not pred and not gold:
return 1.0
pset = {(k, json.dumps(v, sort_keys=True)) for k, v in pred.items()}
gset = {(k, json.dumps(v, sort_keys=True)) for k, v in gold.items()}
if not pset or not gset:
return 0.0
tp = len(pset & gset)
if tp == 0:
return 0.0
prec, rec = tp / len(pset), tp / len(gset)
return 2 * prec * rec / (prec + rec)
def cheapest_tool(row: dict) -> str | None:
"""The lowest-priced tool offered, read from the price in its description."""
best, best_price = None, None
for tool in row.get("tools") or []:
fn = tool.get("function", tool)
m = re.search(r"\$\s*([0-9]*\.?[0-9]+)", fn.get("description", "") or "")
if not m:
continue
price = float(m.group(1))
if best_price is None or price < best_price:
best, best_price = fn.get("name"), price
return best
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="Qwen/Qwen3.5-2B")
ap.add_argument("--adapter", default=None, help="LoRA adapter dir; omit to score the base model")
ap.add_argument("--data", default=None, help="defaults to eval_ours.jsonl if present, else test.jsonl")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--max-new-tokens", type=int, default=192)
ap.add_argument("--out", default=None)
args = ap.parse_args()
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
data_dir = REPO / "data" / "out"
path = Path(args.data) if args.data else (
data_dir / "eval_ours.jsonl" if (data_dir / "eval_ours.jsonl").exists() else data_dir / "test.jsonl")
rows = []
with path.open() as fh:
for line in fh:
if line.strip():
rows.append(json.loads(line))
if args.limit:
rows = rows[: args.limit]
tok = AutoTokenizer.from_pretrained(args.base)
model = AutoModelForCausalLM.from_pretrained(
args.base,
dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
device_map="auto" if torch.cuda.is_available() else None,
)
if args.adapter:
from peft import PeftModel
model = PeftModel.from_pretrained(model, args.adapter)
model.eval()
agg: dict[str, list] = defaultdict(list)
per_task: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
t0 = time.time()
for n, raw in enumerate(rows, 1):
row = normalize_row(raw)
task = row.get("task", "unknown")
gold = target_call(row)
# Feed everything up to the first assistant turn; the model produces it.
cut = next((i for i, m in enumerate(row["messages"]) if m["role"] == "assistant"), len(row["messages"]))
prompt = tok.apply_chat_template(
row["messages"][:cut], tools=row.get("tools") or [],
tokenize=False, add_generation_prompt=True)
ids = tok(prompt, return_tensors="pt", add_special_tokens=False).to(model.device)
with torch.no_grad():
out = model.generate(**ids, max_new_tokens=args.max_new_tokens,
do_sample=False, pad_token_id=tok.pad_token_id or tok.eos_token_id)
text = tok.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=True)
calls, well_formed = parse_tool_calls(text)
if task == "refuse":
correct = len(calls) == 0
agg["refusal_acc"].append(correct)
per_task[task]["refusal_acc"].append(correct)
elif gold:
name_ok = bool(calls) and calls[0]["name"] == gold["name"]
agg["tool_name_acc"].append(name_ok)
per_task[task]["tool_name_acc"].append(name_ok)
agg["args_json_valid"].append(well_formed and bool(calls))
per_task[task]["args_json_valid"].append(well_formed and bool(calls))
pred_args = calls[0]["arguments"] if calls else {}
exact = name_ok and pred_args == gold["arguments"]
f1 = field_f1(pred_args, gold["arguments"]) if name_ok else 0.0
# `price` gold values are 0% extractable BY CONSTRUCTION — the task
# is "pick the cheapest", and the arguments were invented wholesale
# when the row was built. Reporting args_exact 0.0 there measures
# the dataset, not the model; price_acc below is the real metric.
report_args = _extractability_of(raw, gold) is not None and task != "price"
if report_args:
agg["args_exact"].append(exact)
per_task[task]["args_exact"].append(exact)
agg["args_f1"].append(f1)
per_task[task]["args_f1"].append(f1)
# THE HONEST METRIC: exact-match only values the user actually
# stated; for values we synthesised, require the KEY to be
# present and the TYPE to match, never the value. Grading an
# unguessable value rewards fabrication.
ex_ok = _score_extractable(pred_args, gold["arguments"],
_extractability_of(raw, gold)) if name_ok else 0.0
agg["args_exact_extractable"].append(ex_ok)
per_task[task]["args_exact_extractable"].append(ex_ok)
if task == "price":
want = cheapest_tool(row) or gold["name"]
hit = bool(calls) and calls[0]["name"] == want
agg["price_acc"].append(hit)
per_task[task]["price_acc"].append(hit)
if n % 25 == 0:
print(f" {n}/{len(rows)} ({time.time()-t0:.0f}s)", flush=True)
def mean(xs):
return round(sum(float(x) for x in xs) / len(xs), 4) if xs else None
scorecard = {
"base": args.base,
"adapter": args.adapter,
"data": str(path),
"n": len(rows),
"seconds": round(time.time() - t0, 1),
"overall": {k: mean(v) for k, v in sorted(agg.items())},
"by_task": {t: {k: mean(v) for k, v in sorted(m.items())} for t, m in sorted(per_task.items())},
}
out_path = Path(args.out) if args.out else (
REPO / "runs" / f"eval-{'adapter' if args.adapter else 'base'}.json")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(scorecard, indent=2))
print(f"\n## {'tuned' if args.adapter else 'BASE'} — {len(rows)} rows, {scorecard['seconds']}s\n")
print("| metric | score |\n|---|---:|")
for k, v in scorecard["overall"].items():
print(f"| {k} | {v} |")
print("\n| task | " + " | ".join(sorted({k for m in per_task.values() for k in m})) + " |")
keys = sorted({k for m in per_task.values() for k in m})
print("|---" * (len(keys) + 1) + "|")
for t, m in sorted(per_task.items()):
print(f"| {t} | " + " | ".join(str(mean(m.get(k, []))) for k in keys) + " |")
print(f"\nwrote {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())