| """BFCL v4 loading + synthetic per-user session construction. |
| |
| BFCL examples are almost all distinct functions, so |
| there is essentially no natural cross-session repetition. To test the |
| personalization/persistence claim we synthesize realistic repetition: each |
| simulated user is assigned a small set of *signature tasks* (real BFCL |
| examples), and across ordered sessions they re-issue those tasks with perturbed |
| numeric argument values -- i.e. the same tool used again with new inputs, which |
| is exactly the recurring per-user tool usage the memory is meant to exploit. |
| |
| Crucially, we do NOT fabricate the target tool call: every (possibly perturbed) |
| query is sent to the genuinely served gpt-oss-120b model, and the model's real |
| generation is the target. The perturbation only changes the natural-language |
| query; the model decides what to emit. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import random |
| import re |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any |
|
|
| DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "bfcl" |
| SEALTOOLS_DIR = Path(__file__).resolve().parent.parent / "data" / "sealtools" |
| TAU2_DIR = Path(__file__).resolve().parent.parent / "data" / "tau2" |
| TOOLALPACA_DIR = Path(__file__).resolve().parent.parent / "data" / "toolalpaca" |
| APIBANK_DIR = Path(__file__).resolve().parent.parent / "data" / "apibank" |
| TOOLBENCH_DIR = Path(__file__).resolve().parent.parent / "data" / "toolbench" |
|
|
|
|
| def _toolbench_schema(api: dict) -> dict[str, Any]: |
| props, required = {}, [] |
| for p in (api.get("required_parameters") or []): |
| props[p["name"]] = {"type": _SEAL_TYPE_MAP.get(str(p.get("type", "str")).lower(), |
| "string"), |
| "description": str(p.get("description", ""))[:160]} |
| if props[p["name"]]["type"] not in ("string", "integer", "number", |
| "boolean", "array", "object"): |
| props[p["name"]]["type"] = "string" |
| required.append(p["name"]) |
| for p in (api.get("optional_parameters") or []): |
| t = _SEAL_TYPE_MAP.get(str(p.get("type", "str")).lower(), "string") |
| if t not in ("string", "integer", "number", "boolean", "array", "object"): |
| t = "string" |
| props[p["name"]] = {"type": t, |
| "description": str(p.get("description", ""))[:160]} |
| name = str(api.get("api_name", "api")).strip().replace(" ", "_") |
| return {"name": name or "api", |
| "description": str(api.get("api_description", ""))[:300], |
| "parameters": {"type": "dict", "properties": props, |
| "required": required}} |
|
|
|
|
| def load_toolbench(splits: tuple[str, ...] = ("G1_instruction", "G2_instruction", |
| "G3_instruction")) -> list[Task]: |
| """ToolBench (Qin et al., 2023, arXiv:2307.16789) as Tasks, from the REAL |
| dataset (OpenBMB/ToolBench Google-Drive `data/test_instruction/`). Each item |
| has a natural-language `query` and an `api_list` of candidate tools with |
| required/optional parameter schemas.""" |
| tasks: list[Task] = [] |
| for split in splits: |
| f = TOOLBENCH_DIR / "test_instruction" / f"{split}.json" |
| if not f.exists(): |
| continue |
| for r in json.loads(f.read_text()): |
| q = (r.get("query") or "").strip() |
| apis = r.get("api_list") or [] |
| if not q or len(q) < 8 or not apis: |
| continue |
| funcs = [_toolbench_schema(a) for a in apis] |
| |
| seen, uniq = set(), [] |
| for fn in funcs: |
| if fn["name"] and fn["name"] not in seen: |
| seen.add(fn["name"]) |
| uniq.append(fn) |
| if not uniq: |
| continue |
| tid = f"toolbench_{split}_{r.get('query_id')}" |
| tasks.append(Task(id=tid, query=q, functions=uniq, origin_id=tid)) |
| return tasks |
|
|
|
|
| def _toolalpaca_params(desc: str) -> dict[str, Any]: |
| """Parse the 'Parameters: {...}' JSON-ish blob out of a ToolAlpaca function |
| description into BFCL-style {type, description} properties. Best-effort: |
| ToolAlpaca param values are free text ('string. One of: [...]'), so we keep |
| the leading type word and stash the rest as the description.""" |
| m = re.search(r"Parameters:\s*(\{.*?\})\s*(?:\nOutput|$)", desc, re.S) |
| props: dict[str, Any] = {} |
| if not m: |
| return props |
| try: |
| raw = json.loads(m.group(1)) |
| except Exception: |
| return props |
| for pname, pdesc in raw.items(): |
| head = str(pdesc).split(".")[0].strip().lower() |
| typ = head if head in ("string", "integer", "number", "boolean", |
| "array", "object") else "string" |
| prop: dict[str, Any] = {"type": typ, "description": str(pdesc)[:200]} |
| if typ == "array": |
| prop["items"] = {"type": "string"} |
| props[pname] = prop |
| return props |
|
|
|
|
| def load_toolalpaca(splits: tuple[str, ...] = ("eval_simulated", |
| "eval_real")) -> list[Task]: |
| """ToolAlpaca (Tang et al., 2023) as Tasks. Each API's Instructions become |
| queries; the API's Function_Description entries become the tool registry |
| for that query. Downloaded from github.com/tangqiaoyu/ToolAlpaca.""" |
| tasks: list[Task] = [] |
| for split in splits: |
| f = TOOLALPACA_DIR / f"{split}.json" |
| if not f.exists(): |
| continue |
| for tool in json.loads(f.read_text()): |
| fdesc = tool.get("Function_Description") or {} |
| if not isinstance(fdesc, dict): |
| continue |
| funcs = [] |
| for fname, d in fdesc.items(): |
| if fname in ("components", "Response"): |
| continue |
| funcs.append({ |
| "name": fname, |
| "description": str(d).split("\n")[0][:300], |
| "parameters": {"type": "dict", |
| "properties": _toolalpaca_params(str(d)), |
| "required": []}}) |
| if not funcs: |
| continue |
| name = tool.get("Name", "api") |
| for i, instr in enumerate(tool.get("Instructions", []) or []): |
| if not instr or len(instr) < 8: |
| continue |
| tid = f"toolalpaca_{split}_{name}_{i}" |
| tasks.append(Task(id=tid, query=instr, functions=funcs, |
| origin_id=tid)) |
| return tasks |
|
|
|
|
| @dataclass |
| class Task: |
| id: str |
| query: str |
| functions: list[dict[str, Any]] |
| origin_id: str |
|
|
|
|
| def _load_jsonl(path: Path) -> list[dict]: |
| return [json.loads(l) for l in path.read_text().splitlines() if l.strip()] |
|
|
|
|
| def load_bfcl(categories: tuple[str, ...] = ("simple_python", "multiple", |
| "parallel")) -> list[Task]: |
| fmap = { |
| "simple_python": "BFCL_v4_simple_python.json", |
| "multiple": "BFCL_v4_multiple.json", |
| "parallel": "BFCL_v4_parallel.json", |
| } |
| tasks: list[Task] = [] |
| for cat in categories: |
| rows = _load_jsonl(DATA_DIR / fmap[cat]) |
| for r in rows: |
| |
| turns = r["question"][0] |
| user_msg = next((m["content"] for m in turns if m["role"] == "user"), |
| turns[0]["content"]) |
| tasks.append(Task(id=r["id"], query=user_msg, |
| functions=r["function"], origin_id=r["id"])) |
| return tasks |
|
|
|
|
| _SEAL_TYPE_MAP = {"str": "string", "int": "integer", "float": "number", |
| "bool": "boolean", "list": "array", "dict": "object"} |
|
|
|
|
| def _seal_schema(tool: dict) -> dict[str, Any]: |
| """Convert one Seal-Tools registry entry to a BFCL-style function schema. |
| |
| Keep only {type, description} per property: Seal-Tools specs carry |
| non-standard keywords (e.g. stringified enums) that strict JSON-schema |
| validators in serving stacks reject. |
| """ |
| props = {} |
| for pname, spec in (tool.get("parameters") or {}).items(): |
| typ = _SEAL_TYPE_MAP.get(spec.get("type", "str"), |
| spec.get("type", "string")) |
| if typ not in ("string", "integer", "number", "boolean", |
| "array", "object"): |
| typ = "string" |
| clean: dict[str, Any] = {"type": typ, |
| "description": str(spec.get("description", |
| ""))} |
| if typ == "array": |
| clean["items"] = {"type": "string"} |
| props[pname] = clean |
| return { |
| "name": tool["api_name"], |
| "description": tool.get("api_description", ""), |
| "parameters": {"type": "dict", "properties": props, |
| "required": tool.get("required", [])}, |
| } |
|
|
|
|
| def _apibank_schema(spec: dict) -> dict[str, Any]: |
| props = {} |
| for pname, p in (spec.get("input_parameters") or {}).items(): |
| typ = _SEAL_TYPE_MAP.get(str(p.get("type", "str")).lower(), |
| str(p.get("type", "string")).lower()) |
| if typ not in ("string", "integer", "number", "boolean", |
| "array", "object"): |
| typ = "string" |
| prop: dict[str, Any] = {"type": typ, |
| "description": str(p.get("description", ""))[:200]} |
| if typ == "array": |
| prop["items"] = {"type": "string"} |
| props[pname] = prop |
| return {"name": spec["name"], "description": str(spec.get("description", ""))[:300], |
| "parameters": {"type": "dict", "properties": props, |
| "required": list(props.keys())}} |
|
|
|
|
| def load_apibank(levels: tuple[str, ...] = ("level-1", "level-2")) -> list[Task]: |
| """API-Bank (Li et al., 2023, arXiv:2304.08244) as Tasks. Each item's |
| `input` dialogue is the query; the API specs embedded in `instruction` |
| ('API descriptions: {json}\\n{json}...') become the tool registry. |
| Downloaded from HF liminghao1630/API-Bank test-data.""" |
| tasks: list[Task] = [] |
| seen = set() |
| for lvl in levels: |
| f = APIBANK_DIR / f"{lvl}-api.json" |
| if not f.exists(): |
| continue |
| for r in json.loads(f.read_text()): |
| instr = r.get("instruction", "") |
| query = (r.get("input") or "").strip() |
| if not query or len(query) < 8: |
| continue |
| |
| funcs = [] |
| body = instr.split("API descriptions:", 1) |
| if len(body) == 2: |
| for line in body[1].splitlines(): |
| line = line.strip() |
| if line.startswith('{') and '"name"' in line: |
| try: |
| spec = json.loads(line) |
| funcs.append(_apibank_schema(spec)) |
| except Exception: |
| pass |
| if not funcs: |
| continue |
| tid = f"apibank_{lvl}_{r.get('file','')}_{r.get('id')}" |
| if tid in seen: |
| continue |
| seen.add(tid) |
| tasks.append(Task(id=tid, query=query, functions=funcs, |
| origin_id=tid)) |
| return tasks |
|
|
|
|
| def load_sealtools(n_distractors: int = 3) -> list[Task]: |
| """Load the Seal-Tools in-domain test split (Wu et al., 2024) as Tasks. |
| |
| Second benchmark for the acceptance experiment: same Task interface as |
| load_bfcl(), so the simulated-user construction and the 3-arm replay are |
| IDENTICAL to the BFCL runs. Each example keeps its first gold API's schema |
| and adds ``n_distractors`` deterministic distractor schemas (stable hash of |
| the example id), shuffled deterministically so the gold schema's position |
| carries no signal. Only ~49% of Seal-Tools queries contain perturbable |
| numerals (vs. most BFCL queries), so the per-user recurrence statistics are |
| natively different from BFCL -- more exact repeats, less argument drift. |
| """ |
| import hashlib |
| registry = {t["api_name"]: t |
| for t in _load_jsonl(SEALTOOLS_DIR / "tool.jsonl")} |
| names = sorted(registry) |
| tasks: list[Task] = [] |
| for r in _load_jsonl(SEALTOOLS_DIR / "test_in_domain.jsonl"): |
| gold = r["calling"][0]["api"] |
| if gold not in registry: |
| continue |
| h = int(hashlib.sha256(r["id"].encode()).hexdigest(), 16) |
| rng = random.Random(h) |
| distractors = [n for n in rng.sample(names, n_distractors + 1) |
| if n != gold][:n_distractors] |
| funcs = [_seal_schema(registry[n]) for n in [gold] + distractors] |
| rng.shuffle(funcs) |
| tasks.append(Task(id=r["id"], query=r["query"], functions=funcs, |
| origin_id=r["id"])) |
| return tasks |
|
|
|
|
| def load_tau2(pool_size: int = 900, seed: int = 20260716) -> list[Task]: |
| """Load tau2-bench frozen-trajectory decision points (Barres et al., 2025). |
| |
| Third benchmark: multi-turn conversational decision points extracted from |
| tau2-bench's shipped reference trajectories (see tau2_extract.py for the |
| frozen-conversation design that keeps the 3-arm comparison controlled). |
| Each Task's query is a rendered transcript prefix; functions are the full |
| tool registry of the task's domain (14 airline / 16 retail tools), so the |
| model must also pick the right tool, not just fill arguments. A |
| deterministic sample of ``pool_size`` keeps the pool comparable to the |
| BFCL (800) / Seal-Tools (700) pools. |
| """ |
| tools = {dom: json.loads((TAU2_DIR / f"tools_{dom}.json").read_text()) |
| for dom in ("airline", "retail")} |
| rows = _load_jsonl(TAU2_DIR / "decision_points.jsonl") |
| rng = random.Random(seed) |
| rng.shuffle(rows) |
| rows = rows[:pool_size] |
| return [Task(id=r["id"], query=r["query"], |
| functions=tools[r["domain"]], origin_id=r["id"]) |
| for r in rows] |
|
|
|
|
| _NUM_RE = re.compile(r"(?<![\w.])(-?\d+(?:\.\d+)?)(?![\w.])") |
|
|
|
|
| def perturb_numeric(query: str, rng: random.Random) -> str: |
| """Replace standalone numbers in a query with new values of similar scale. |
| |
| Returns the query unchanged if it contains no substitutable numbers (in |
| which case the task recurs as an exact repeat, which is also realistic). |
| """ |
| matches = list(_NUM_RE.finditer(query)) |
| if not matches: |
| return query |
| out, last = [], 0 |
| for m in matches: |
| out.append(query[last:m.start()]) |
| tok = m.group(1) |
| if "." in tok: |
| base = float(tok) |
| lo, hi = max(0.1, base * 0.4), base * 1.9 + 1.0 |
| out.append(f"{rng.uniform(lo, hi):.1f}") |
| else: |
| base = int(tok) |
| if abs(base) <= 1: |
| out.append(tok) |
| else: |
| lo, hi = max(2, int(abs(base) * 0.4)), int(abs(base) * 1.9) + 2 |
| val = rng.randint(lo, hi) |
| out.append(str(-val if base < 0 else val)) |
| last = m.end() |
| out.append(query[last:]) |
| return "".join(out) |
|
|