@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,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,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()
|