@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,150 @@
1
+ // a raw local model behind an OpenAI-style endpoint, for a benchmark's own calls such as its user
2
+ import { createHash } from 'crypto'
3
+ import http from 'bare-http1'
4
+ import fs from 'fs'
5
+ import path from 'path'
6
+ import process from 'process'
7
+ import { fileURLToPath } from 'url'
8
+ import { errorMessage } from '../../lib/guards.ts'
9
+ import { resolveLlmGenerationParams } from '../../lib/models/llm-presets.ts'
10
+ import type { ModelLifecycleSdk } from '../../lib/models/model-pool.ts'
11
+ import { resolveModelSrc } from '../../lib/models/model-source.ts'
12
+ import { createSdkLoader } from '../../lib/sdk-loader.ts'
13
+ import { configureSdkStorage } from '../../lib/sdk-storage.ts'
14
+ import { MODEL_CACHE_PATH_ENV, resolveStorage } from '../../lib/storage.ts'
15
+
16
+ const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
17
+ const port = Number(process.env.QVAC_OPENAI_PORT ?? 8765)
18
+ const CTX_SIZE = 16384
19
+ const REASONING_OFF = false
20
+ const STARTED = Date.now()
21
+ // τ² ends a task only on its exact ###STOP### tokens; a small customer model drops the hashes
22
+ const END_TOKENS = process.env.QVAC_OPENAI_END_TOKENS === '1'
23
+ const END_WORDS = new Set(['STOP', 'TRANSFER', 'OUT-OF-SCOPE'])
24
+
25
+ interface ChatMessage {
26
+ readonly role: string
27
+ readonly content?: unknown
28
+ }
29
+
30
+ interface ChatRequest {
31
+ readonly model: string
32
+ readonly messages: ReadonlyArray<ChatMessage>
33
+ }
34
+
35
+ const log = fs.openSync(path.join(REPO_ROOT, 'output', 'openai.log'), 'a')
36
+ console.log =
37
+ console.info =
38
+ console.debug =
39
+ console.warn =
40
+ console.error =
41
+ (...args: unknown[]) => fs.writeSync(log, `${args.map(String).join(' ')}\n`)
42
+
43
+ const storage = resolveStorage('dev', {
44
+ root: path.join(REPO_ROOT, 'output', 'bench', 'sdk-root'),
45
+ models: process.env[MODEL_CACHE_PATH_ENV]
46
+ })
47
+ configureSdkStorage(storage.sdk)
48
+ const sdk = await createSdkLoader().load()
49
+ // lunte-disable-next-line qvac/no-as-cast -- SDK loadModel overloads omit this slice, as in index.ts
50
+ const lifecycle = sdk as unknown as ModelLifecycleSdk
51
+ const loaded = new Map<string, Promise<string>>()
52
+ // the model answers one request at a time; the rest wait their turn
53
+ let queue: Promise<unknown> = Promise.resolve()
54
+
55
+ http
56
+ .createServer((req, res) => {
57
+ let body = ''
58
+ req.on('data', (chunk: unknown) => {
59
+ body += String(chunk)
60
+ })
61
+ req.on('end', () => {
62
+ const answered = queue.then(() => chat(JSON.parse(body)))
63
+ queue = answered.catch(noop)
64
+ answered.then(
65
+ (reply) => respond(res, 200, reply),
66
+ (err: unknown) => respond(res, 500, { error: { message: errorMessage(err) } })
67
+ )
68
+ })
69
+ })
70
+ .listen(port, '127.0.0.1')
71
+ process.stdout.write(`qvac openai endpoint on http://127.0.0.1:${port}/v1\n`)
72
+
73
+ async function chat(request: ChatRequest) {
74
+ const model = request.model.replace(/^openai\//, '')
75
+ const result = sdk.completion({
76
+ modelId: await modelId(model),
77
+ history: request.messages.map((message) => ({
78
+ role: message.role,
79
+ content: String(message.content ?? '')
80
+ })),
81
+ stream: true,
82
+ kvCache: conversationKey(model, request.messages),
83
+ // the simulated user speaks without reasoning first, as the benchmark's own user model would
84
+ generationParams: resolveLlmGenerationParams(model, REASONING_OFF)
85
+ })
86
+ let content = ''
87
+ for await (const token of result.tokenStream) {
88
+ content += token
89
+ }
90
+
91
+ await result.final
92
+
93
+ return {
94
+ id: `qvac-${Date.now()}`,
95
+ object: 'chat.completion',
96
+ created: Math.floor(Date.now() / 1000),
97
+ model: request.model,
98
+ choices: [
99
+ {
100
+ index: 0,
101
+ message: {
102
+ role: 'assistant',
103
+ content: endToken(content.replace(/<think>[\s\S]*?<\/think>/g, '').trim())
104
+ },
105
+ finish_reason: 'stop'
106
+ }
107
+ ]
108
+ }
109
+ }
110
+
111
+ function endToken(reply: string): string {
112
+ const word = reply.replace(/^[#\s]+|[#.!\s]+$/g, '').toUpperCase()
113
+ if (!END_TOKENS || !END_WORDS.has(word) || reply === `###${word}###`) {
114
+ return reply
115
+ }
116
+
117
+ console.log(`end token normalised: ${JSON.stringify(reply)} -> ###${word}###`)
118
+
119
+ return `###${word}###`
120
+ }
121
+
122
+ // the SDK sends only what a key has not seen, so a key names one conversation of one endpoint run
123
+ function conversationKey(model: string, messages: ReadonlyArray<ChatMessage>): string {
124
+ const opening = JSON.stringify([STARTED, model, ...messages.slice(0, 2)])
125
+ return `openai-${createHash('sha256').update(opening).digest('hex').slice(0, 24)}`
126
+ }
127
+
128
+ function modelId(model: string): Promise<string> {
129
+ let id = loaded.get(model)
130
+ if (!id) {
131
+ id = resolveModelSrc(sdk, model).then((modelSrc) =>
132
+ lifecycle.loadModel({
133
+ modelSrc,
134
+ modelType: 'llamacpp-completion',
135
+ modelConfig: { ctx_size: CTX_SIZE, system_prompt: '' }
136
+ })
137
+ )
138
+ loaded.set(model, id)
139
+ }
140
+
141
+ return id
142
+ }
143
+
144
+ function respond(res: http.ServerResponse, status: 200 | 500, payload: unknown): void {
145
+ res.statusCode = status
146
+ res.setHeader('content-type', 'application/json')
147
+ res.end(JSON.stringify(payload))
148
+ }
149
+
150
+ function noop(): void {}
@@ -0,0 +1,3 @@
1
+ from .client import QvacBridge
2
+
3
+ __all__ = ["QvacBridge"]
@@ -0,0 +1,42 @@
1
+ """What a tau2 airline write will do and cost, quoted to the user before it runs."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ # the airline policy charges each non-free bag at this price
7
+ BAG_PRICE = 50
8
+
9
+
10
+ def _book_reservation(args: dict[str, Any]) -> str:
11
+ passengers = len(args.get("passengers") or [])
12
+ flights = ", ".join(f"{f.get('flight_number')} on {f.get('date')}" for f in args.get("flights") or [])
13
+ charged = sum(float(p.get("amount") or 0) for p in args.get("payment_methods") or [])
14
+ return (
15
+ f"Book {passengers} passenger(s) in {args.get('cabin')} on {flights}, "
16
+ f"{args.get('total_baggages', 0)} bag(s) ({int(args.get('nonfree_baggages') or 0)} paid), "
17
+ f"insurance {args.get('insurance')}, for a total of ${charged:g}."
18
+ )
19
+
20
+
21
+ def _update_reservation_baggages(args: dict[str, Any]) -> str:
22
+ nonfree = int(args.get("nonfree_baggages") or 0)
23
+ return (
24
+ f"Set reservation {args.get('reservation_id')} to {args.get('total_baggages')} bag(s), "
25
+ f"{nonfree} paid at ${BAG_PRICE} each (${nonfree * BAG_PRICE}), paid with {args.get('payment_id')}."
26
+ )
27
+
28
+
29
+ BUILDERS = {
30
+ "book_reservation": _book_reservation,
31
+ "cancel_reservation": lambda args: f"Cancel reservation {args.get('reservation_id')}.",
32
+ "update_reservation_baggages": _update_reservation_baggages,
33
+ "send_certificate": lambda args: f"Send a ${args.get('amount')} certificate to {args.get('user_id')}.",
34
+ }
35
+
36
+
37
+ def quote(name: str, args: dict[str, Any]) -> str:
38
+ """A plain summary when the call has no builder or its arguments are malformed."""
39
+ try:
40
+ return BUILDERS[name](args)
41
+ except (KeyError, AttributeError, TypeError, ValueError):
42
+ return f"Call {name} with {json.dumps(args)}."
@@ -0,0 +1,26 @@
1
+ """Whether a simulated user's reply to a quoted write approves it."""
2
+
3
+ import re
4
+
5
+ # a model user rarely answers a bare "yes": it restates the change it confirms, or says it already did
6
+ AGREES = re.compile(
7
+ r"^(yes|yeah|yep|sure|ok|okay|correct)\b"
8
+ r"|\b(i|i've|i have|i've already|i already|i have already) (confirm|confirmed)\b"
9
+ r"|\balready gave my (verbal )?confirmation\b"
10
+ r"|^\w+ confirmed\b"
11
+ r"|^(proceed|go ahead)\b"
12
+ r"|\bplease (proceed|go ahead)\b"
13
+ )
14
+ # a reply that disputes the quote or changes it goes back to the model as words
15
+ HOLDS = re.compile(r"\b(but|however|wait|before|clarify|discrepancy|instead|cannot|can't|unable|not)\b|doesn't match")
16
+ SENTENCE_END = re.compile(r"[.!?](\s|$)")
17
+
18
+
19
+ def confirms(reply: str | None) -> bool:
20
+ text = (reply or "").strip().lower().replace("’", "'")
21
+ agrees = AGREES.search(text)
22
+ if not agrees or HOLDS.search(text):
23
+ return False
24
+ # a question up to the confirming sentence doubts the quote; one after it is the user's next request
25
+ end = SENTENCE_END.search(text, agrees.end())
26
+ return "?" not in text[: end.end() if end else len(text)]
@@ -0,0 +1,152 @@
1
+ """BFCL v4 multi-turn with the harness as the model: BFCL's own entries, backends and checker.
2
+
3
+ Each user turn is one harness run over the conversation so far. A call the run makes executes
4
+ on gorilla's Python backends as BFCL's inference executes it, and the entry is scored by BFCL's
5
+ multi_turn_checker as its eval runner calls it. Needs bfcl-eval from the pinned gorilla commit.
6
+ """
7
+
8
+ import copy
9
+ import json
10
+ from typing import Any, Awaitable, Callable
11
+
12
+ from bfcl_eval.constants.default_prompts import (
13
+ DEFAULT_USER_PROMPT_FOR_ADDITIONAL_FUNCTION_FC,
14
+ MAXIMUM_STEP_LIMIT,
15
+ )
16
+ from bfcl_eval.constants.enums import ModelStyle
17
+ from bfcl_eval.constants.type_mappings import GORILLA_TO_OPENAPI
18
+ from bfcl_eval.eval_checker.multi_turn_eval.multi_turn_checker import multi_turn_checker
19
+ from bfcl_eval.eval_checker.multi_turn_eval.multi_turn_utils import (
20
+ execute_multi_turn_func_call,
21
+ is_empty_execute_response,
22
+ )
23
+ from bfcl_eval.model_handler.utils import convert_to_function_call, convert_to_tool
24
+ from bfcl_eval.utils import load_dataset_entry, load_ground_truth_entry
25
+
26
+ from .client import Execute
27
+ from .speed import add_speed, no_speed
28
+
29
+ CATEGORIES = [
30
+ "multi_turn_base",
31
+ "multi_turn_miss_func",
32
+ "multi_turn_miss_param",
33
+ "multi_turn_long_context",
34
+ ]
35
+ # BFCL force-quits a turn on its step past the limit, so the run may take one round more
36
+ MAX_TOOL_ROUNDS = MAXIMUM_STEP_LIMIT + 1
37
+
38
+ Run = Callable[[list[dict[str, Any]], list[dict[str, Any]], Execute], Awaitable[dict[str, Any]]]
39
+
40
+
41
+ def load(category: str, limit: int | None) -> list[tuple[dict, list]]:
42
+ """Entries with their ground truth, spread evenly across the category as data.ts spreads them."""
43
+ entries = load_dataset_entry(category)
44
+ answers = {row["id"]: row["ground_truth"] for row in load_ground_truth_entry(category)}
45
+ count = min(limit or len(entries), len(entries))
46
+ step = max(1, len(entries) // count)
47
+ picked = [entry for index, entry in enumerate(entries) if index % step == 0][:count]
48
+ return [(entry, answers[entry["id"]]) for entry in picked]
49
+
50
+
51
+ def specs(functions: list[dict]) -> list[dict]:
52
+ """The docs as BFCL's function-calling handlers send them."""
53
+ tools = convert_to_tool(functions, GORILLA_TO_OPENAPI, ModelStyle.OPENAI_COMPLETIONS)
54
+ return [{key: tool["function"][key] for key in ("name", "description", "parameters")} for tool in tools]
55
+
56
+
57
+ class Episode:
58
+ """One entry. Its backends live in BFCL's own instances, keyed by run_name, so a run never sees another's state."""
59
+
60
+ def __init__(self, entry: dict, answer: list, run_name: str):
61
+ self.entry = entry
62
+ self.answer = answer
63
+ self.run_name = run_name
64
+ self.category = entry["id"].rsplit("_", 1)[0]
65
+ self.turns: list[list[list[dict]]] = []
66
+ # the calls the backends ran, in order; a call the harness refused never reaches them
67
+ self.ran: list[dict] = []
68
+ self.replies: list[str] = []
69
+ self.speed = no_speed()
70
+
71
+ # async only because the bridge client awaits every tool
72
+ async def execute(self, name: str, args: dict[str, Any]) -> Any:
73
+ self.ran.append({name: args})
74
+ results, _ = execute_multi_turn_func_call(
75
+ convert_to_function_call([{name: args}]),
76
+ self.entry["initial_config"],
77
+ self.entry["involved_classes"],
78
+ self.run_name,
79
+ self.entry["id"],
80
+ long_context="long_context" in self.category or "composite" in self.category,
81
+ is_evaL_run=False,
82
+ )
83
+ return as_sent(results[0])
84
+
85
+ async def play(self, run: Run) -> dict[str, Any]:
86
+ functions = copy.deepcopy(self.entry["function"])
87
+ holdout = self.entry.get("missed_function", {})
88
+ history: list[dict[str, Any]] = []
89
+ for index, messages in enumerate(self.entry["question"]):
90
+ if str(index) in holdout:
91
+ functions.extend(holdout[str(index)])
92
+ messages = [{"role": "user", "content": DEFAULT_USER_PROMPT_FOR_ADDITIONAL_FUNCTION_FC}]
93
+ history.extend(messages)
94
+ ran = len(self.ran)
95
+ done = await run(history, specs(functions), self.execute)
96
+ history.extend(done["history"])
97
+ steps = executed(done["history"], self.ran[ran:])
98
+ self.turns.append(steps)
99
+ self.replies.append(done["reply"])
100
+ add_speed(self.speed, done.get("metrics"))
101
+ # BFCL fails an entry whose inference raised, without scoring it
102
+ if done["error"]:
103
+ return failed("multi_turn:inference_error", done["error"])
104
+ if len(steps) > MAXIMUM_STEP_LIMIT:
105
+ break
106
+ return self.score()
107
+
108
+ def score(self) -> dict[str, Any]:
109
+ """BFCL's eval runner's own steps; it is not imported, as it imports every provider's SDK."""
110
+ if len(self.turns) != len(self.answer):
111
+ return failed(
112
+ "multi_turn:force_terminated",
113
+ f"Model was force-terminated during inference phase. The length of the model result turns ({len(self.turns)}) does not match the length of the ground truth turns ({len(self.answer)}).",
114
+ )
115
+ decoded = [
116
+ [calls for calls in map(convert_to_function_call, turn) if not is_empty_execute_response(calls)]
117
+ for turn in self.turns
118
+ ]
119
+ result = multi_turn_checker(decoded, self.answer, self.entry, self.category, self.run_name)
120
+ if result["valid"]:
121
+ return {"valid": True, "error_type": None, "error": None}
122
+ return failed(result["error_type"], result["error_message"])
123
+
124
+
125
+ def as_sent(result: str) -> Any:
126
+ """BFCL sends a backend's JSON as it is; the harness encodes a tool's result, so it gets the object."""
127
+ try:
128
+ parsed = json.loads(result)
129
+ except ValueError:
130
+ return result
131
+ return parsed if isinstance(parsed, dict) else result
132
+
133
+
134
+ def executed(history: list[dict[str, Any]], ran: list[dict]) -> list[list[dict]]:
135
+ """The calls the backends ran, grouped by the round that made them, as BFCL's steps.
136
+
137
+ A call the harness refused, such as one cut off at the output limit, never ran, so it is not scored.
138
+ """
139
+ pending = list(ran)
140
+ steps = []
141
+ for turn in history:
142
+ step = []
143
+ for call in turn.get("toolCalls") or []:
144
+ if pending and {call["name"]: call["arguments"]} == pending[0]:
145
+ step.append(pending.pop(0))
146
+ if step:
147
+ steps.append(step)
148
+ return steps + ([pending] if pending else [])
149
+
150
+
151
+ def failed(error_type: str, message: str) -> dict[str, Any]:
152
+ return {"valid": False, "error_type": error_type, "error": message}
@@ -0,0 +1,88 @@
1
+ """Drive @qvac/harness from Python: the harness runs the loop, the caller's environment runs the tools."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ FRAME = "\x1eqvac "
9
+ # the native addon prints whole prompts on the same stdout, one line each
10
+ LINE_LIMIT = 1 << 26
11
+
12
+ Execute = Callable[[str, dict[str, Any]], Awaitable[Any]]
13
+
14
+
15
+ class QvacBridge:
16
+ def __init__(self, repo: str, model: str, bare: str | None = None):
17
+ self.repo = repo
18
+ self.model = model
19
+ self.bare = bare or os.path.join(repo, "node_modules", ".bin", "bare")
20
+ self._proc: asyncio.subprocess.Process | None = None
21
+ self._runs = 0
22
+
23
+ async def start(self) -> None:
24
+ self._proc = await asyncio.create_subprocess_exec(
25
+ self.bare,
26
+ "bench/bridge/serve.ts",
27
+ cwd=self.repo,
28
+ stdin=asyncio.subprocess.PIPE,
29
+ stdout=asyncio.subprocess.PIPE,
30
+ stderr=asyncio.subprocess.DEVNULL,
31
+ limit=LINE_LIMIT,
32
+ )
33
+ await self._expect("ready")
34
+
35
+ async def run(
36
+ self,
37
+ messages: list[dict[str, Any]],
38
+ tools: list[dict[str, Any]],
39
+ execute: Execute,
40
+ system: str | None = None,
41
+ max_tool_rounds: int | None = None,
42
+ cache_key: str | None = None,
43
+ ) -> dict[str, Any]:
44
+ run_id = f"run-{self._runs}"
45
+ self._runs += 1
46
+ run = {"type": "run", "id": run_id, "model": self.model, "system": system, "messages": messages, "tools": tools}
47
+ # the harness reads a null limit as invalid, so an unset one is left out
48
+ if max_tool_rounds is not None:
49
+ run["maxToolRounds"] = max_tool_rounds
50
+ if cache_key is not None:
51
+ run["cacheKey"] = cache_key
52
+ self._send(run)
53
+ while True:
54
+ frame = await self._frame()
55
+ if frame["type"] == "call":
56
+ result = await execute(frame["name"], frame["args"])
57
+ self._send({"type": "result", "callId": frame["callId"], "result": result})
58
+ elif frame["type"] == "done" and frame["id"] == run_id:
59
+ return frame
60
+
61
+ def forget(self, cache_key: str) -> None:
62
+ """Drops a finished conversation's cached prefill."""
63
+ self._send({"type": "forget", "cacheKey": cache_key})
64
+
65
+ async def close(self) -> None:
66
+ if self._proc is None:
67
+ return
68
+ self._proc.stdin.close()
69
+ await self._proc.wait()
70
+ self._proc = None
71
+
72
+ def _send(self, frame: dict[str, Any]) -> None:
73
+ self._proc.stdin.write((json.dumps(frame) + "\n").encode())
74
+
75
+ async def _expect(self, kind: str) -> dict[str, Any]:
76
+ frame = await self._frame()
77
+ if frame["type"] != kind:
78
+ raise RuntimeError(f"expected {kind}, got {frame}")
79
+ return frame
80
+
81
+ async def _frame(self) -> dict[str, Any]:
82
+ while True:
83
+ line = await self._proc.stdout.readline()
84
+ if not line:
85
+ raise RuntimeError("the harness bridge exited")
86
+ text = line.decode(errors="replace")
87
+ if text.startswith(FRAME):
88
+ return json.loads(text[len(FRAME):])
@@ -0,0 +1,85 @@
1
+ """A Harbor agent: the harness's loop with one bash tool that runs in the task container.
2
+
3
+ QVAC_HARNESS_REPO=$PWD PYTHONPATH=bench/bridge/python uv tool run harbor run \\
4
+ --dataset terminal-bench@2.0 -a qvac_bench.harbor_agent:QvacHarnessAgent \\
5
+ -m Qwen3.5-9B-Q4_K_M -n 1 --ak max_tool_rounds=10
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import time
11
+
12
+ from harbor.agents.base import BaseAgent
13
+ from harbor.environments.base import BaseEnvironment
14
+ from harbor.models.agent.context import AgentContext
15
+
16
+ from .client import QvacBridge
17
+ from .tools import BASH
18
+
19
+ # keep a tool result inside a small model's context
20
+ OUTPUT_TAIL = 8000
21
+ COMMAND_TIMEOUT_SEC = 180
22
+ # what timeout(1) exits with
23
+ TIMEOUT_RETURN_CODE = 124
24
+
25
+ SYSTEM = (
26
+ "You are working in a Linux terminal to complete a task. Use the bash tool to inspect "
27
+ "the environment, make changes and verify them. When the task is complete, say so."
28
+ )
29
+
30
+
31
+ class QvacHarnessAgent(BaseAgent):
32
+ def __init__(self, *args, max_tool_rounds: int | str | None = None, **kwargs):
33
+ super().__init__(*args, **kwargs)
34
+ self.max_tool_rounds = None if max_tool_rounds in (None, "") else int(max_tool_rounds)
35
+
36
+ @staticmethod
37
+ def name() -> str:
38
+ return "qvac-harness"
39
+
40
+ def version(self) -> str | None:
41
+ return "bridge-1"
42
+
43
+ async def setup(self, environment: BaseEnvironment) -> None:
44
+ return None
45
+
46
+ async def run(self, instruction: str, environment: BaseEnvironment, context: AgentContext) -> None:
47
+ model = self.model_name or "Qwen3.5-9B-Q4_K_M"
48
+ bridge = QvacBridge(os.environ["QVAC_HARNESS_REPO"], model)
49
+ # the load average shows whether other work on the machine slowed the run
50
+ log = open(os.path.join(self.logs_dir, "qvac.jsonl"), "a")
51
+
52
+ def write(row: dict) -> None:
53
+ log.write(json.dumps(row) + "\n")
54
+ log.flush()
55
+
56
+ async def execute(name: str, args: dict) -> dict:
57
+ command = str(args.get("command", ""))
58
+ started = time.monotonic()
59
+ # DockerEnvironment.exec raises on timeout; the model gets it as a failed command
60
+ try:
61
+ result = await environment.exec(command, timeout_sec=COMMAND_TIMEOUT_SEC)
62
+ stdout, stderr, return_code = result.stdout or "", result.stderr or "", result.return_code
63
+ except RuntimeError as error:
64
+ stdout, stderr, return_code = "", str(error), TIMEOUT_RETURN_CODE
65
+ write({"type": "call", "command": command, "return_code": return_code, "seconds": time.monotonic() - started})
66
+ return {"stdout": stdout[-OUTPUT_TAIL:], "stderr": stderr[-OUTPUT_TAIL:], "return_code": return_code}
67
+
68
+ write({"type": "start", "model": model, "max_tool_rounds": self.max_tool_rounds, "load": os.getloadavg()})
69
+ try:
70
+ await bridge.start()
71
+ done = await bridge.run(
72
+ [{"role": "user", "content": instruction}],
73
+ [BASH],
74
+ execute,
75
+ system=SYSTEM,
76
+ max_tool_rounds=self.max_tool_rounds,
77
+ )
78
+ write(done)
79
+ rounds = (done.get("metrics") or {}).get("rounds") or []
80
+ context.n_input_tokens = sum(record.get("promptTokens") or 0 for record in rounds)
81
+ context.n_output_tokens = sum(record.get("generatedTokens") or 0 for record in rounds)
82
+ finally:
83
+ write({"type": "end", "load": os.getloadavg()})
84
+ log.close()
85
+ await bridge.close()
@@ -0,0 +1,107 @@
1
+ """A synchronous bridge session, for benchmarks whose agent API returns one message at a time."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import time
7
+ from typing import Any
8
+
9
+ from .client import FRAME
10
+
11
+ # the addon prints whole prompts on the same stdout; the harness's own log lines are short
12
+ TRACE_LINE_LIMIT = 4000
13
+
14
+
15
+ class QvacSession:
16
+ def __init__(self, repo: str, model: str, bare: str | None = None, trace: str | None = None):
17
+ self.model = model
18
+ self._trace = open(trace, "a") if trace else None
19
+ self._runs = 0
20
+ # the run whose frames this session answers; a frame of any other run is stale
21
+ self._active: str | None = None
22
+ self._proc = subprocess.Popen(
23
+ [bare or os.path.join(repo, "node_modules", ".bin", "bare"), "bench/bridge/serve.ts"],
24
+ cwd=repo,
25
+ stdin=subprocess.PIPE,
26
+ stdout=subprocess.PIPE,
27
+ stderr=subprocess.DEVNULL,
28
+ env={**os.environ, "QVAC_HARNESS_LOG": "1"} if trace else None,
29
+ text=True,
30
+ bufsize=1,
31
+ )
32
+ self._expect("ready")
33
+
34
+ def start(
35
+ self,
36
+ messages: list[dict[str, Any]],
37
+ tools: list[dict[str, Any]],
38
+ system: str | None,
39
+ now: int | None = None,
40
+ ) -> dict[str, Any]:
41
+ """Starts a run and returns its first call, approve or done frame."""
42
+ self.finish()
43
+ self._runs += 1
44
+ self._active = f"run-{self._runs}"
45
+ self._send({
46
+ "type": "run",
47
+ "id": self._active,
48
+ "model": self.model,
49
+ "system": system,
50
+ "messages": messages,
51
+ "tools": tools,
52
+ "now": now,
53
+ })
54
+ return self._next()
55
+
56
+ def answer(self, call_id: str, result: Any) -> dict[str, Any]:
57
+ """Returns a call's result and the active run's next call, approve or done frame."""
58
+ self._send({"type": "result", "callId": call_id, "result": result})
59
+ return self._next()
60
+
61
+ def decide(self, call_id: str, approved: bool) -> dict[str, Any]:
62
+ """Settles a write the run asked the user about and returns its next frame."""
63
+ self._send({"type": "decision", "callId": call_id, "approved": approved})
64
+ return self._next()
65
+
66
+ def finish(self) -> None:
67
+ """Ends a run the benchmark abandoned mid-turn, so its frames never reach the next task."""
68
+ if self._active is None:
69
+ return
70
+ self._send({"type": "cancel", "id": self._active})
71
+ while self._next()["type"] != "done":
72
+ pass
73
+
74
+ def close(self) -> None:
75
+ self._proc.stdin.close()
76
+ self._proc.wait()
77
+ if self._trace:
78
+ self._trace.close()
79
+
80
+ def _send(self, frame: dict[str, Any]) -> None:
81
+ self._proc.stdin.write(json.dumps(frame) + "\n")
82
+ self._proc.stdin.flush()
83
+
84
+ def _next(self) -> dict[str, Any]:
85
+ while True:
86
+ frame = self._frame()
87
+ if frame.get("id") != self._active or frame["type"] not in ("call", "approve", "done"):
88
+ continue
89
+ if frame["type"] == "done":
90
+ self._active = None
91
+ return frame
92
+
93
+ def _expect(self, kind: str) -> None:
94
+ frame = self._frame()
95
+ if frame["type"] != kind:
96
+ raise RuntimeError(f"expected {kind}, got {frame}")
97
+
98
+ def _frame(self) -> dict[str, Any]:
99
+ while True:
100
+ line = self._proc.stdout.readline()
101
+ if not line:
102
+ raise RuntimeError("the harness bridge exited")
103
+ if line.startswith(FRAME):
104
+ return json.loads(line[len(FRAME):])
105
+ if self._trace and len(line) < TRACE_LINE_LIMIT:
106
+ self._trace.write(f"{time.strftime('%H:%M:%S')} {line}")
107
+ self._trace.flush()