@qvac/harness 1.0.0-rc.2 → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +12 -12
  2. package/bench/README.md +94 -0
  3. package/bench/bfcl/checker.ts +313 -0
  4. package/bench/bfcl/data.ts +93 -0
  5. package/bench/bfcl/observe.ts +93 -0
  6. package/bench/bfcl/run.ts +213 -0
  7. package/bench/bfcl/tools.ts +72 -0
  8. package/bench/bridge/bridge.ts +392 -0
  9. package/bench/bridge/openai.ts +150 -0
  10. package/bench/bridge/python/qvac_bench/__init__.py +3 -0
  11. package/bench/bridge/python/qvac_bench/airline.py +42 -0
  12. package/bench/bridge/python/qvac_bench/approval.py +26 -0
  13. package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
  14. package/bench/bridge/python/qvac_bench/client.py +88 -0
  15. package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
  16. package/bench/bridge/python/qvac_bench/session.py +107 -0
  17. package/bench/bridge/python/qvac_bench/speed.py +30 -0
  18. package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
  19. package/bench/bridge/python/qvac_bench/tools.py +11 -0
  20. package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
  21. package/bench/bridge/python/run_tau2.py +150 -0
  22. package/bench/bridge/python/smoke.py +47 -0
  23. package/bench/bridge/python/test_airline.py +28 -0
  24. package/bench/bridge/python/test_approval.py +57 -0
  25. package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
  26. package/bench/bridge/python/test_harbor_agent.py +107 -0
  27. package/bench/bridge/serve.ts +52 -0
  28. package/bench/bridge/transcript.ts +52 -0
  29. package/bench/bun-tsconfig.json +15 -0
  30. package/bench/cpu-main.mjs +216 -0
  31. package/bench/cpu.ts +457 -0
  32. package/bench/evidence/2026-09-28-review15.md +225 -0
  33. package/bench/evidence/redact-bun1.3.14.json +92 -0
  34. package/bench/evidence/redact-node24.11.json +92 -0
  35. package/bench/graph-metrics.ts +43 -0
  36. package/bench/harness.ts +73 -0
  37. package/bench/issue129-matrix.mjs +722 -0
  38. package/bench/redact-adversarial.ts +256 -0
  39. package/bench/repeat-cpu.mjs +281 -0
  40. package/bench/report.ts +1075 -0
  41. package/bench/skill-eval/harness.ts +30 -0
  42. package/bench/skill-eval/laws.ts +37 -0
  43. package/bench/skill-eval/mermaid/client.ts +132 -0
  44. package/bench/skill-eval/mermaid/validate.mjs +115 -0
  45. package/bench/skill-eval/pack.ts +61 -0
  46. package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
  47. package/bench/skill-eval/packs/diagrams/index.ts +50 -0
  48. package/bench/skill-eval/packs/diagrams/score.ts +74 -0
  49. package/bench/skill-eval/packs/index.ts +5 -0
  50. package/bench/skill-eval/packs/weather/cases.ts +279 -0
  51. package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
  52. package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
  53. package/bench/skill-eval/packs/weather/index.ts +55 -0
  54. package/bench/skill-eval/packs/weather/score.ts +92 -0
  55. package/bench/skill-eval/report.ts +194 -0
  56. package/bench/skill-eval/run.ts +316 -0
  57. package/bench/speed.ts +80 -0
  58. package/package.json +4 -13
@@ -0,0 +1,30 @@
1
+ """A run's speed as sums, in the shape of bench/speed.ts, so runs add up."""
2
+
3
+
4
+ def add_speed(speed: dict, metrics: dict | None) -> None:
5
+ """Adds one run's metrics to a task's sums, in the shape of bench/speed.ts."""
6
+ if not metrics:
7
+ return
8
+ speed["runs"] += 1
9
+ for record in metrics.get("rounds", []):
10
+ speed["rounds"] += 1
11
+ prompt, prefill_rate = record.get("promptTokens"), record.get("promptTokensPerSecond")
12
+ if prompt and prefill_rate:
13
+ speed["promptTokens"] += prompt
14
+ speed["prefillMs"] += prompt / prefill_rate * 1000
15
+ generated, decode_rate = record.get("generatedTokens"), record.get("tokensPerSecond")
16
+ if generated and decode_rate:
17
+ speed["generatedTokens"] += generated
18
+ speed["decodeMs"] += generated / decode_rate * 1000
19
+ speed["modelMs"] += metrics.get("modelMs", 0)
20
+ speed["toolMs"] += metrics.get("toolMs", 0)
21
+ speed["wallMs"] += metrics["durationMs"]
22
+ if metrics.get("timeToFirstToken") is not None:
23
+ speed["ttftMs"].append(metrics["timeToFirstToken"])
24
+
25
+
26
+ def no_speed() -> dict:
27
+ return {
28
+ "runs": 0, "rounds": 0, "promptTokens": 0, "prefillMs": 0, "generatedTokens": 0,
29
+ "decodeMs": 0, "modelMs": 0, "toolMs": 0, "wallMs": 0, "ttftMs": [],
30
+ }
@@ -0,0 +1,106 @@
1
+ """A tau2-bench agent whose every turn is a harness run; tau2's orchestrator still runs the tools.
2
+
3
+ A call frame becomes an assistant message carrying that one tool call, and the ToolMessage
4
+ tau2 sends back answers it, so the same harness run continues until it replies to the user.
5
+ A write first comes back as an approve frame: the agent quotes it to the user, and a reply that
6
+ confirms it lets the same run make the call. Any other answer starts a new run, so the model
7
+ reads the user's words as the conversation goes on.
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from typing import Any, Optional
13
+
14
+ from tau2.agent.base_agent import HalfDuplexAgent
15
+ from tau2.agent.llm_agent import AGENT_INSTRUCTION, SYSTEM_PROMPT
16
+ from tau2.data_model.message import AssistantMessage, MultiToolMessage, ToolCall, ToolMessage, UserMessage
17
+ from tau2.environment.toolkit import Tool
18
+
19
+ from .airline import quote
20
+ from .approval import confirms
21
+ from .session import QvacSession
22
+ from .speed import add_speed, no_speed
23
+
24
+
25
+ # the airline domain's writes; every other tool reads, so it runs without asking
26
+ WRITE_TOOLS = {
27
+ "book_reservation",
28
+ "cancel_reservation",
29
+ "update_reservation_baggages",
30
+ "update_reservation_flights",
31
+ "update_reservation_passengers",
32
+ "send_certificate",
33
+ }
34
+
35
+
36
+ class QvacTau2Agent(HalfDuplexAgent[dict]):
37
+ def __init__(self, tools: list[Tool], domain_policy: str, session: QvacSession, now: int | None = None):
38
+ super().__init__(tools=tools, domain_policy=domain_policy)
39
+ self.session = session
40
+ # the policy's own date, in epoch ms: the harness otherwise stamps today on every turn
41
+ self.now = now
42
+ self.system = SYSTEM_PROMPT.format(domain_policy=domain_policy, agent_instruction=AGENT_INSTRUCTION)
43
+ self.specs = [
44
+ {**spec, "write": spec["name"] in WRITE_TOOLS}
45
+ for spec in (tool.openai_schema["function"] for tool in tools)
46
+ ]
47
+ # a turn the harness ended without a reply, and why: the SDK's errors count against the agent
48
+ self.replies = 0
49
+ self.failed_turns = 0
50
+ self.errors: dict[str, int] = {}
51
+ # τ² runs carry no cache key, so every round primes the cache cold
52
+ self.speed = no_speed()
53
+
54
+ def get_init_state(self, message_history: Optional[list] = None) -> dict:
55
+ return {"history": [], "calls": {}}
56
+
57
+ def generate_next_message(self, message: Any, state: dict) -> tuple[AssistantMessage, dict]:
58
+ if isinstance(message, UserMessage):
59
+ asked = state.pop("approval", None)
60
+ state["history"].append({"role": "user", "content": message.content or ""})
61
+ if asked and confirms(message.content):
62
+ frame = self.session.decide(asked, True)
63
+ else:
64
+ frame = self.session.start(state["history"], self.specs, self.system, self.now)
65
+ elif isinstance(message, ToolMessage):
66
+ frame = self.answer(message, state)
67
+ elif isinstance(message, MultiToolMessage):
68
+ frame = None
69
+ for tool_message in message.tool_messages:
70
+ frame = self.answer(tool_message, state)
71
+ else:
72
+ raise TypeError(f"unexpected message {type(message).__name__}")
73
+
74
+ if frame["type"] == "call":
75
+ state["calls"][frame["callId"]] = frame["name"]
76
+ replayed = {"id": frame["callId"], "name": frame["name"], "arguments": frame["args"]}
77
+ state["history"].append({"role": "assistant", "content": "", "toolCalls": [replayed]})
78
+ call = ToolCall(id=frame["callId"], name=frame["name"], arguments=frame["args"])
79
+ return AssistantMessage(role="assistant", content=None, tool_calls=[call]), state
80
+
81
+ if frame["type"] == "approve":
82
+ state["approval"] = frame["callId"]
83
+ ask = f"{quote(frame['name'], frame['args'])} Reply yes to confirm."
84
+ state["history"].append({"role": "assistant", "content": ask})
85
+ return AssistantMessage(role="assistant", content=ask), state
86
+
87
+ self.replies += 1
88
+ add_speed(self.speed, frame.get("metrics"))
89
+ if not frame["reply"].strip() or frame["error"]:
90
+ self.failed_turns += 1
91
+ error = frame["error"] or "empty reply"
92
+ self.errors[error] = self.errors.get(error, 0) + 1
93
+ print(f"[qvac] failed turn: calls={frame['calls']} error={error!r}", file=sys.stderr, flush=True)
94
+ reply = frame["reply"].strip() or "I'm sorry, could you say that again?"
95
+ state["history"].append({"role": "assistant", "content": reply})
96
+ return AssistantMessage(role="assistant", content=reply), state
97
+
98
+ def answer(self, message: ToolMessage, state: dict) -> dict[str, Any]:
99
+ # the run got the text as its result, so the replay carries it as a JSON string
100
+ state["history"].append({
101
+ "role": "tool",
102
+ "toolCallId": message.id,
103
+ "name": state["calls"][message.id],
104
+ "content": json.dumps(message.content),
105
+ })
106
+ return self.session.answer(message.id, message.content)
@@ -0,0 +1,11 @@
1
+ """Tool specs a benchmark hands the bridge; the benchmark's environment runs them."""
2
+
3
+ BASH = {
4
+ "name": "bash",
5
+ "description": "Run one shell command in the task's Linux container and get its stdout, stderr and exit code.",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {"command": {"type": "string", "description": "The command to run"}},
9
+ "required": ["command"],
10
+ },
11
+ }
@@ -0,0 +1,95 @@
1
+ """BFCL v4 multi-turn with the harness as the model, scored by BFCL's own checker.
2
+
3
+ output/venvs/bfcl/bin/python bench/bridge/python/run_bfcl_multi_turn.py \\
4
+ --models Qwen3.5-2B-Q4_K_M,Qwen3.5-9B-Q4_K_M --limit 10 --out output/bfcl-multi-turn/run
5
+ """
6
+
7
+ import argparse
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import time
14
+
15
+ sys.path.insert(0, os.path.dirname(__file__))
16
+ from qvac_bench.bfcl_multi_turn import CATEGORIES, MAX_TOOL_ROUNDS, Episode, load # noqa: E402
17
+ from qvac_bench.client import QvacBridge # noqa: E402
18
+
19
+ REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
20
+
21
+
22
+ async def run_model(model: str, cases: list, args: argparse.Namespace, stamp: str, commit: str) -> None:
23
+ bridge = QvacBridge(REPO, model)
24
+ await bridge.start()
25
+ run_name = f"qvac_{model}_{stamp}"
26
+ try:
27
+ for entry, answer in cases:
28
+ episode = Episode(entry, answer, run_name)
29
+ key = f"bfcl-multi-turn-{stamp}-{model}-{entry['id']}"
30
+
31
+ def run(messages, tools, execute, key=key):
32
+ return bridge.run(messages, tools, execute, max_tool_rounds=args.max_tool_rounds, cache_key=key)
33
+
34
+ started = time.time()
35
+ verdict = await episode.play(run)
36
+ bridge.forget(key)
37
+ row = {
38
+ "model": model,
39
+ "category": episode.category,
40
+ "id": entry["id"],
41
+ "commit": commit,
42
+ **verdict,
43
+ "turns": len(answer),
44
+ "played": len(episode.turns),
45
+ "calls": [[call for step in turn for call in step] for turn in episode.turns],
46
+ "replies": [reply[:300] for reply in episode.replies],
47
+ "seconds": round(time.time() - started, 1),
48
+ "speed": episode.speed,
49
+ }
50
+ with open(os.path.join(args.out, "runs.jsonl"), "a") as f:
51
+ f.write(json.dumps(row) + "\n")
52
+ outcome = "pass" if verdict["valid"] else verdict["error_type"]
53
+ print(f"{model} {entry['id']}: {outcome} in {row['seconds']}s", flush=True)
54
+ finally:
55
+ await bridge.close()
56
+
57
+
58
+ def summary(out: str) -> str:
59
+ with open(os.path.join(out, "runs.jsonl")) as f:
60
+ rows = [json.loads(line) for line in f if line.strip()]
61
+ lines = []
62
+ for model in dict.fromkeys(row["model"] for row in rows):
63
+ for category in CATEGORIES:
64
+ own = [row for row in rows if row["model"] == model and row["category"] == category]
65
+ if own:
66
+ passed = sum(row["valid"] for row in own)
67
+ lines.append(f"{model} {category}: {passed}/{len(own)} ({round(passed / len(own) * 100)}%)")
68
+ return "\n".join(lines)
69
+
70
+
71
+ def main() -> None:
72
+ parser = argparse.ArgumentParser()
73
+ parser.add_argument("--categories", default=",".join(CATEGORIES))
74
+ parser.add_argument("--models", default="Qwen3.5-9B-Q4_K_M")
75
+ parser.add_argument("--limit", type=int, default=None, help="entries per category, spread evenly across it")
76
+ parser.add_argument("--max-tool-rounds", type=int, default=MAX_TOOL_ROUNDS, help="per turn; default BFCL's step limit")
77
+ parser.add_argument("--out", default=None)
78
+ args = parser.parse_args()
79
+
80
+ stamp = time.strftime("%Y%m%d-%H%M%S")
81
+ args.out = args.out or os.path.join(REPO, "output", "bfcl-multi-turn", stamp)
82
+ os.makedirs(args.out, exist_ok=True)
83
+ # report.ts reads every row, so a second run into the same dir would count each entry twice
84
+ if os.path.exists(os.path.join(args.out, "runs.jsonl")):
85
+ sys.exit(f"{args.out} already holds a run; pick another --out")
86
+ commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=REPO, capture_output=True, text=True).stdout.strip()
87
+ cases = [case for category in args.categories.split(",") for case in load(category, args.limit)]
88
+ print(f"{len(cases)} BFCL multi-turn entries -> {args.out}", flush=True)
89
+ for model in args.models.split(","):
90
+ asyncio.run(run_model(model, cases, args, stamp, commit))
91
+ print(summary(args.out))
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
@@ -0,0 +1,150 @@
1
+ """tau2-bench with the harness as the agent and a local model as the simulated user.
2
+
3
+ output/venvs/tau2/bin/python bench/bridge/python/run_tau2.py --domain airline --tasks 10
4
+
5
+ Scores are tau2's own rewards, but not comparable to its leaderboard, whose user is GPT-4.1.
6
+ """
7
+
8
+ import argparse
9
+ import datetime
10
+ import json
11
+ import os
12
+ import re
13
+ import signal
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+ sys.path.insert(0, os.path.dirname(__file__))
19
+ from qvac_bench.session import QvacSession # noqa: E402
20
+ from qvac_bench.tau2_agent import QvacTau2Agent # noqa: E402
21
+ from tau2.evaluator.evaluator import EvaluationType # noqa: E402
22
+ from tau2.orchestrator.orchestrator import Orchestrator # noqa: E402
23
+ from tau2.runner import build_environment, build_user, get_tasks, run_simulation # noqa: E402
24
+
25
+ REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
26
+ PORT = 8765
27
+
28
+
29
+ def start_user_endpoint() -> subprocess.Popen:
30
+ endpoint = subprocess.Popen(
31
+ [os.path.join(REPO, "node_modules", ".bin", "bare"), "bench/bridge/openai.ts"],
32
+ cwd=REPO,
33
+ env={**os.environ, "QVAC_OPENAI_PORT": str(PORT), "QVAC_OPENAI_END_TOKENS": "1"},
34
+ stdout=subprocess.PIPE,
35
+ stderr=subprocess.DEVNULL,
36
+ text=True,
37
+ # the bin shim spawns the runtime, so only a process group takes both down
38
+ start_new_session=True,
39
+ )
40
+ for line in endpoint.stdout:
41
+ if "endpoint on" in line:
42
+ return endpoint
43
+ raise RuntimeError("the user endpoint exited before listening; is port %d taken?" % PORT)
44
+
45
+
46
+ def stop(process: subprocess.Popen) -> None:
47
+ os.killpg(process.pid, signal.SIGTERM)
48
+ process.wait()
49
+
50
+
51
+ # the task files run roughly from refusals to multi-step changes, so the first N are not a sample
52
+ def sample(tasks: list, count: int | None) -> list:
53
+ if not count or count >= len(tasks):
54
+ return tasks
55
+ step = len(tasks) / count
56
+ return [tasks[int(index * step)] for index in range(count)]
57
+
58
+
59
+ # the policy's own hand-off line; τ²'s customer should answer it with ###TRANSFER###, which ends the task
60
+ TRANSFER_MESSAGE = "YOU ARE BEING TRANSFERRED TO A HUMAN AGENT"
61
+ # τ²'s end tokens are ###STOP### and the like; a customer that drops the hashes never ends the task
62
+ END_WORDS = {"STOP", "TRANSFER", "OUT-OF-SCOPE"}
63
+
64
+
65
+ def customer_never_ended(result) -> bool:
66
+ if not str(result.termination_reason).endswith("MAX_STEPS"):
67
+ return False
68
+ handed_off = any(m.role == "assistant" and TRANSFER_MESSAGE in (m.content or "") for m in result.messages)
69
+ bare_end = any(m.role == "user" and (m.content or "").strip(" #.").upper() in END_WORDS for m in result.messages)
70
+ return handed_off or bare_end
71
+
72
+
73
+ # a task whose expected actions change the database, as opposed to one the policy says to refuse
74
+ def needs_write(task) -> bool:
75
+ actions = (task.evaluation_criteria.actions or []) if task.evaluation_criteria else []
76
+ return any(not action.name.startswith(("get_", "search_", "list_", "calculate", "think", "transfer")) for action in actions)
77
+
78
+
79
+ # tau2 policies state their own present ("The current time is 2024-05-15 15:00:00 EST.")
80
+ def policy_now(policy: str) -> int | None:
81
+ match = re.search(r"current time is (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) EST", policy)
82
+ if not match:
83
+ return None
84
+ est = datetime.timezone(datetime.timedelta(hours=-5))
85
+ return int(datetime.datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=est).timestamp() * 1000)
86
+
87
+
88
+ def main() -> None:
89
+ parser = argparse.ArgumentParser()
90
+ parser.add_argument("--domain", default="airline")
91
+ parser.add_argument("--model", default="Qwen3.5-9B-Q4_K_M")
92
+ parser.add_argument("--user-model", default="Qwen3.5-9B-Q4_K_M")
93
+ parser.add_argument("--tasks", type=int, default=None)
94
+ parser.add_argument("--task-ids", default=None, help="comma-separated ids, instead of a sample")
95
+ parser.add_argument("--max-steps", type=int, default=100)
96
+ parser.add_argument("--out", default=os.path.join(REPO, "output", "tau2"))
97
+ args = parser.parse_args()
98
+
99
+ os.makedirs(args.out, exist_ok=True)
100
+ out_file = os.path.join(args.out, f"{args.domain}-{args.model}-{int(time.time())}.jsonl")
101
+ endpoint = start_user_endpoint()
102
+ commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=REPO, capture_output=True, text=True).stdout.strip()
103
+ session = QvacSession(REPO, args.model, trace=out_file.replace(".jsonl", ".trace"))
104
+ user_llm_args = {"api_base": f"http://127.0.0.1:{PORT}/v1", "api_key": "local", "temperature": 0.0}
105
+ rewards = []
106
+ try:
107
+ chosen = args.task_ids.split(",") if args.task_ids else None
108
+ tasks = get_tasks(args.domain, task_ids=chosen) if chosen else sample(get_tasks(args.domain), args.tasks)
109
+ for task in tasks:
110
+ env = build_environment(args.domain)
111
+ agent = QvacTau2Agent(env.get_tools(), env.get_policy(), session, policy_now(env.get_policy()))
112
+ user = build_user("user_simulator", env, task, llm=f"openai/{args.user_model}", llm_args=user_llm_args)
113
+ orchestrator = Orchestrator(
114
+ domain=args.domain, agent=agent, user=user, environment=env, task=task,
115
+ max_steps=args.max_steps, max_errors=10, seed=42,
116
+ )
117
+ started = time.time()
118
+ try:
119
+ result = run_simulation(orchestrator, evaluation_type=EvaluationType.ALL)
120
+ finally:
121
+ session.finish()
122
+ reward = result.reward_info.reward if result.reward_info else 0.0
123
+ rewards.append(reward)
124
+ row = {
125
+ "task": task.id,
126
+ "commit": commit,
127
+ "writes": needs_write(task),
128
+ "reward": reward,
129
+ "termination": str(getattr(result, "termination_reason", "")),
130
+ "breakdown": {str(k): v for k, v in (result.reward_info.reward_breakdown or {}).items()} if result.reward_info else {},
131
+ "messages": len(result.messages),
132
+ "customer_never_ended": customer_never_ended(result),
133
+ "replies": agent.replies,
134
+ "failed_turns": agent.failed_turns,
135
+ "errors": agent.errors,
136
+ "speed": agent.speed,
137
+ "seconds": round(time.time() - started, 1),
138
+ }
139
+ with open(out_file, "a") as f:
140
+ f.write(json.dumps(row) + "\n")
141
+ print(f"{task.id}: reward {reward} in {row['seconds']}s ({row['messages']} messages)", flush=True)
142
+ finally:
143
+ session.close()
144
+ stop(endpoint)
145
+
146
+ print(f"{args.domain} {args.model}: pass^1 {sum(rewards) / max(1, len(rewards)):.3f} over {len(rewards)} tasks -> {out_file}")
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
@@ -0,0 +1,47 @@
1
+ """Smoke the bridge without a benchmark: a real model, one bash tool, a throwaway local folder."""
2
+
3
+ import asyncio
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+
9
+ sys.path.insert(0, os.path.dirname(__file__))
10
+ from qvac_bench import QvacBridge # noqa: E402
11
+ from qvac_bench.tools import BASH # noqa: E402
12
+
13
+ REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
14
+
15
+
16
+ async def main(model: str) -> None:
17
+ workdir = os.path.realpath(tempfile.mkdtemp(prefix="qvac-bridge-"))
18
+ with open(os.path.join(workdir, "notes.txt"), "w") as f:
19
+ f.write("launch: 14 March\n")
20
+
21
+ async def execute(name: str, args: dict) -> dict:
22
+ # the model's commands run on this machine, so confine them: no network, writes only in workdir
23
+ profile = f'(version 1)(allow default)(deny network*)(deny file-write*)(allow file-write* (subpath "{workdir}") (literal "/dev/null"))'
24
+ done = subprocess.run(
25
+ ["sandbox-exec", "-p", profile, "/bin/sh", "-c", args["command"]],
26
+ cwd=workdir, capture_output=True, text=True, timeout=30,
27
+ )
28
+ return {"stdout": done.stdout, "stderr": done.stderr, "return_code": done.returncode}
29
+
30
+ bridge = QvacBridge(REPO, model)
31
+ await bridge.start()
32
+ try:
33
+ done = await bridge.run(
34
+ [{"role": "user", "content": "Create a file called answer.txt containing the launch date from notes.txt."}],
35
+ [BASH],
36
+ execute,
37
+ system="You work in a terminal. Use the bash tool.",
38
+ )
39
+ finally:
40
+ await bridge.close()
41
+
42
+ answer = os.path.join(workdir, "answer.txt")
43
+ print({"reply": done["reply"][:200], "calls": done["calls"], "error": done["error"]})
44
+ print("answer.txt:", open(answer).read().strip() if os.path.exists(answer) else None)
45
+
46
+
47
+ asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "Qwen3.5-4B-Q4_K_M"))
@@ -0,0 +1,28 @@
1
+ """python3 -m unittest test_airline, from this folder: tau2 is not needed."""
2
+
3
+ import unittest
4
+
5
+ from qvac_bench.airline import quote
6
+
7
+
8
+ class Quote(unittest.TestCase):
9
+ def test_flights_that_are_not_objects_fall_back(self):
10
+ self.assertTrue(quote("book_reservation", {"flights": ["HAT1"]}).startswith("Call book_reservation with "))
11
+
12
+ def test_a_passenger_count_falls_back(self):
13
+ self.assertTrue(quote("book_reservation", {"passengers": 5}).startswith("Call book_reservation with "))
14
+
15
+ def test_a_string_amount_is_priced(self):
16
+ self.assertIn("total of $100.", quote("book_reservation", {"payment_methods": [{"amount": "100"}]}))
17
+ self.assertTrue(quote("book_reservation", {"payment_methods": [{"amount": "lots"}]}).startswith("Call "))
18
+
19
+ def test_a_string_bag_count_is_priced(self):
20
+ self.assertIn("2 paid at $50 each ($100)", quote("update_reservation_baggages", {"nonfree_baggages": "2"}))
21
+ self.assertIn("(2 paid)", quote("book_reservation", {"nonfree_baggages": "2"}))
22
+
23
+ def test_an_unknown_tool_is_summarised(self):
24
+ self.assertEqual(quote("get_user_details", {"user_id": "u1"}), 'Call get_user_details with {"user_id": "u1"}.')
25
+
26
+
27
+ if __name__ == "__main__":
28
+ unittest.main()
@@ -0,0 +1,57 @@
1
+ """python3 -m unittest test_approval, from this folder: tau2 is not needed."""
2
+
3
+ import unittest
4
+
5
+ from qvac_bench.approval import confirms
6
+
7
+ # replies a Qwen3.5 simulated user gave to a quoted tau2 write
8
+ APPROVALS = [
9
+ "yes",
10
+ "Yes.",
11
+ "Yes, please proceed with the cabin upgrade.",
12
+ "Okay, I confirm the upgrade to business class for XEHM4B using the card ending in 2135.",
13
+ "I confirm.",
14
+ "I confirm the cabin upgrade to business class for both passengers on flights HAT235 and HAT298.",
15
+ "I already confirmed the name change from Mei Lee to Mei Garcia. Please proceed with updating it.",
16
+ "I’ve already confirmed that FDZ0T5 should be cancelled. Please go ahead and cancel it now.",
17
+ "I understand you're asking for confirmation. I already gave my verbal confirmation earlier.",
18
+ "Upgrade confirmed. Now cancel both reservations: XEHM4B and 59XX6W.",
19
+ "I understand. Please proceed with the upgrade and cancellation for XEHM4B. Could you please handle this?",
20
+ "I already confirmed the cancellation of FDZ0T5 earlier. Should I proceed with PUNERT as well?",
21
+ ]
22
+
23
+ HOLDS = [
24
+ "",
25
+ "I",
26
+ "###STOP###",
27
+ "make it two bags",
28
+ "The total cost is $255, which doesn't match the flight price of $103 plus free baggage.",
29
+ "I see the confirmation request. However, there's a discrepancy in the date.",
30
+ "I need to clarify something before confirming. The payment method I prefer is a gift card.",
31
+ "Wait, before I confirm anything, let me clarify: you're asking to cancel all flights?",
32
+ "I'm sorry, but I cannot proceed with the cabin upgrade at this time.",
33
+ "I see the booking is confirmed with a total of $400.",
34
+ "I have successfully booked your flight!",
35
+ "Yes. But make it two bags.",
36
+ "Since I already confirmed yes verbally, could you please proceed with updating my reservation?",
37
+ "I don't have my date of birth in my user profile. Can you please proceed without it?",
38
+ ]
39
+
40
+
41
+ class Confirms(unittest.TestCase):
42
+ def test_a_confirmation_approves(self):
43
+ for reply in APPROVALS:
44
+ with self.subTest(reply=reply):
45
+ self.assertTrue(confirms(reply))
46
+
47
+ def test_a_change_question_or_dispute_goes_back_to_the_model(self):
48
+ for reply in HOLDS:
49
+ with self.subTest(reply=reply):
50
+ self.assertFalse(confirms(reply))
51
+
52
+ def test_no_reply_holds(self):
53
+ self.assertFalse(confirms(None))
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()