@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.
- package/README.md +12 -12
- package/bench/README.md +94 -0
- package/bench/bfcl/checker.ts +313 -0
- package/bench/bfcl/data.ts +93 -0
- package/bench/bfcl/observe.ts +93 -0
- package/bench/bfcl/run.ts +213 -0
- package/bench/bfcl/tools.ts +72 -0
- package/bench/bridge/bridge.ts +392 -0
- package/bench/bridge/openai.ts +150 -0
- package/bench/bridge/python/qvac_bench/__init__.py +3 -0
- package/bench/bridge/python/qvac_bench/airline.py +42 -0
- package/bench/bridge/python/qvac_bench/approval.py +26 -0
- package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
- package/bench/bridge/python/qvac_bench/client.py +88 -0
- package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
- package/bench/bridge/python/qvac_bench/session.py +107 -0
- package/bench/bridge/python/qvac_bench/speed.py +30 -0
- package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
- package/bench/bridge/python/qvac_bench/tools.py +11 -0
- package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
- package/bench/bridge/python/run_tau2.py +150 -0
- package/bench/bridge/python/smoke.py +47 -0
- package/bench/bridge/python/test_airline.py +28 -0
- package/bench/bridge/python/test_approval.py +57 -0
- package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
- package/bench/bridge/python/test_harbor_agent.py +107 -0
- package/bench/bridge/serve.ts +52 -0
- package/bench/bridge/transcript.ts +52 -0
- package/bench/bun-tsconfig.json +15 -0
- package/bench/cpu-main.mjs +216 -0
- package/bench/cpu.ts +457 -0
- package/bench/evidence/2026-09-28-review15.md +225 -0
- package/bench/evidence/redact-bun1.3.14.json +92 -0
- package/bench/evidence/redact-node24.11.json +92 -0
- package/bench/graph-metrics.ts +43 -0
- package/bench/harness.ts +73 -0
- package/bench/issue129-matrix.mjs +722 -0
- package/bench/redact-adversarial.ts +256 -0
- package/bench/repeat-cpu.mjs +281 -0
- package/bench/report.ts +1075 -0
- package/bench/skill-eval/harness.ts +30 -0
- package/bench/skill-eval/laws.ts +37 -0
- package/bench/skill-eval/mermaid/client.ts +132 -0
- package/bench/skill-eval/mermaid/validate.mjs +115 -0
- package/bench/skill-eval/pack.ts +61 -0
- package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
- package/bench/skill-eval/packs/diagrams/index.ts +50 -0
- package/bench/skill-eval/packs/diagrams/score.ts +74 -0
- package/bench/skill-eval/packs/index.ts +5 -0
- package/bench/skill-eval/packs/weather/cases.ts +279 -0
- package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
- package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
- package/bench/skill-eval/packs/weather/index.ts +55 -0
- package/bench/skill-eval/packs/weather/score.ts +92 -0
- package/bench/skill-eval/report.ts +194 -0
- package/bench/skill-eval/run.ts +316 -0
- package/bench/speed.ts +80 -0
- 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()
|