toolcallbench 1.0.0__py3-none-any.whl
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.
- toolcallbench/__init__.py +3 -0
- toolcallbench/__main__.py +6 -0
- toolcallbench/backends/__init__.py +43 -0
- toolcallbench/backends/base.py +42 -0
- toolcallbench/backends/ollama.py +86 -0
- toolcallbench/backends/openai_compat.py +96 -0
- toolcallbench/backends/scripted.py +90 -0
- toolcallbench/checks.py +225 -0
- toolcallbench/cli.py +436 -0
- toolcallbench/config.py +99 -0
- toolcallbench/data/configs/m4-16gb.yaml +22 -0
- toolcallbench/data/configs/my-models.yaml +19 -0
- toolcallbench/data/configs/quant-sweep-qwen3-8b.yaml +17 -0
- toolcallbench/data/configs/smoke-scripted.yaml +6 -0
- toolcallbench/data/fixtures/repo/README.md +17 -0
- toolcallbench/data/fixtures/repo/config/settings.json +7 -0
- toolcallbench/data/fixtures/repo/data/books.csv +7 -0
- toolcallbench/data/fixtures/repo/data/members.csv +4 -0
- toolcallbench/data/fixtures/repo/pytest.ini +3 -0
- toolcallbench/data/fixtures/repo/scripts/report.py +23 -0
- toolcallbench/data/fixtures/repo/src/shelf/__init__.py +3 -0
- toolcallbench/data/fixtures/repo/src/shelf/catalog.py +33 -0
- toolcallbench/data/fixtures/repo/src/shelf/fees.py +20 -0
- toolcallbench/data/fixtures/repo/src/shelf/loans.py +46 -0
- toolcallbench/data/fixtures/repo/src/shelf/models.py +30 -0
- toolcallbench/data/fixtures/repo/src/shelf/settings.py +12 -0
- toolcallbench/data/fixtures/repo/src/shelf/utils.py +19 -0
- toolcallbench/data/fixtures/repo/tests/test_catalog.py +19 -0
- toolcallbench/data/fixtures/repo/tests/test_fees.py +16 -0
- toolcallbench/data/fixtures/repo/tests/test_loans.py +32 -0
- toolcallbench/data/fixtures/repo/tests/test_utils.py +14 -0
- toolcallbench/data/tasks/chain-01.yaml +27 -0
- toolcallbench/data/tasks/chain-02.yaml +20 -0
- toolcallbench/data/tasks/chain-03.yaml +19 -0
- toolcallbench/data/tasks/chain-04.yaml +38 -0
- toolcallbench/data/tasks/edit-01.yaml +23 -0
- toolcallbench/data/tasks/edit-02.yaml +19 -0
- toolcallbench/data/tasks/edit-03.yaml +32 -0
- toolcallbench/data/tasks/edit-04.yaml +23 -0
- toolcallbench/data/tasks/edit-05.yaml +23 -0
- toolcallbench/data/tasks/failure-01.yaml +10 -0
- toolcallbench/data/tasks/failure-02.yaml +20 -0
- toolcallbench/data/tasks/failure-03.yaml +20 -0
- toolcallbench/data/tasks/read-01.yaml +15 -0
- toolcallbench/data/tasks/read-02.yaml +15 -0
- toolcallbench/data/tasks/read-03.yaml +16 -0
- toolcallbench/data/tasks/read-04.yaml +15 -0
- toolcallbench/data/tasks/read-05.yaml +16 -0
- toolcallbench/data/tasks/run-01.yaml +15 -0
- toolcallbench/data/tasks/run-02.yaml +15 -0
- toolcallbench/data/tasks/run-03.yaml +15 -0
- toolcallbench/models.py +79 -0
- toolcallbench/patching.py +130 -0
- toolcallbench/report.py +197 -0
- toolcallbench/runner.py +90 -0
- toolcallbench/store.py +225 -0
- toolcallbench/tasks.py +155 -0
- toolcallbench/tools.py +306 -0
- toolcallbench-1.0.0.dist-info/METADATA +113 -0
- toolcallbench-1.0.0.dist-info/RECORD +64 -0
- toolcallbench-1.0.0.dist-info/WHEEL +5 -0
- toolcallbench-1.0.0.dist-info/entry_points.txt +2 -0
- toolcallbench-1.0.0.dist-info/licenses/LICENSE +21 -0
- toolcallbench-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Model backends and a factory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..models import Task
|
|
7
|
+
from .base import Backend, BackendError
|
|
8
|
+
from .ollama import OllamaBackend
|
|
9
|
+
from .openai_compat import OpenAICompatBackend
|
|
10
|
+
from .scripted import PROFILES, ScriptedBackend
|
|
11
|
+
|
|
12
|
+
BACKEND_KINDS = ("ollama", "openai-compat", "scripted")
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BACKEND_KINDS",
|
|
16
|
+
"PROFILES",
|
|
17
|
+
"Backend",
|
|
18
|
+
"BackendError",
|
|
19
|
+
"OllamaBackend",
|
|
20
|
+
"OpenAICompatBackend",
|
|
21
|
+
"ScriptedBackend",
|
|
22
|
+
"make_backend",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_backend(
|
|
27
|
+
kind: str,
|
|
28
|
+
*,
|
|
29
|
+
model: str,
|
|
30
|
+
host: str,
|
|
31
|
+
options: dict[str, Any],
|
|
32
|
+
think: bool | None,
|
|
33
|
+
timeout: float,
|
|
34
|
+
tasks: list[Task],
|
|
35
|
+
profile: str = "oracle",
|
|
36
|
+
) -> Backend:
|
|
37
|
+
if kind == "ollama":
|
|
38
|
+
return OllamaBackend(model, host=host, options=options, think=think, timeout=timeout)
|
|
39
|
+
if kind == "openai-compat":
|
|
40
|
+
return OpenAICompatBackend(model, base_url=host, options=options, timeout=timeout)
|
|
41
|
+
if kind == "scripted":
|
|
42
|
+
return ScriptedBackend(tasks, profile=profile)
|
|
43
|
+
raise ValueError(f"unknown backend '{kind}'; choose from {', '.join(BACKEND_KINDS)}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Backend interface and a small JSON-over-HTTP helper (standard library only)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..models import BackendResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendError(RuntimeError):
|
|
13
|
+
"""The model server could not be reached or returned an error. Not a model failure."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Backend:
|
|
17
|
+
name = "backend"
|
|
18
|
+
|
|
19
|
+
def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> BackendResponse:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
def check(self) -> None:
|
|
23
|
+
"""Raise BackendError if the server or the model is not usable. Called once before a sweep."""
|
|
24
|
+
|
|
25
|
+
def describe(self) -> dict[str, Any]:
|
|
26
|
+
"""Facts about the server and model, recorded in the session settings."""
|
|
27
|
+
return {"backend": self.name}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def request_json(method: str, url: str, payload: dict[str, Any] | None, timeout: float) -> Any:
|
|
31
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
32
|
+
request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
|
|
33
|
+
try:
|
|
34
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
35
|
+
return json.loads(response.read().decode("utf-8"))
|
|
36
|
+
except urllib.error.HTTPError as exc:
|
|
37
|
+
body = exc.read().decode("utf-8", "replace")[:500]
|
|
38
|
+
raise BackendError(f"HTTP {exc.code} from {url}: {body}") from exc
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
raise BackendError(f"{url} returned invalid JSON") from exc
|
|
41
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
42
|
+
raise BackendError(f"cannot reach {url}: {exc}") from exc
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Ollama backend: POST /api/chat with tools, non-streaming."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..models import BackendResponse, RawToolCall
|
|
7
|
+
from .base import Backend, BackendError, request_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OllamaBackend(Backend):
|
|
11
|
+
name = "ollama"
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
model: str,
|
|
16
|
+
host: str = "http://localhost:11434",
|
|
17
|
+
options: dict[str, Any] | None = None,
|
|
18
|
+
think: bool | None = None,
|
|
19
|
+
timeout: float = 300.0,
|
|
20
|
+
keep_alive: str = "30m",
|
|
21
|
+
):
|
|
22
|
+
self.model = model
|
|
23
|
+
self.host = host.rstrip("/")
|
|
24
|
+
self.options = dict(options or {})
|
|
25
|
+
self.think = think
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self.keep_alive = keep_alive
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------ server facts
|
|
30
|
+
|
|
31
|
+
def version(self) -> str:
|
|
32
|
+
return str(request_json("GET", f"{self.host}/api/version", None, self.timeout).get("version", "unknown"))
|
|
33
|
+
|
|
34
|
+
def list_models(self) -> list[str]:
|
|
35
|
+
data = request_json("GET", f"{self.host}/api/tags", None, self.timeout)
|
|
36
|
+
return [m.get("name", "") for m in data.get("models", [])]
|
|
37
|
+
|
|
38
|
+
def show(self) -> dict[str, Any]:
|
|
39
|
+
return request_json("POST", f"{self.host}/api/show", {"model": self.model}, self.timeout)
|
|
40
|
+
|
|
41
|
+
def check(self) -> None:
|
|
42
|
+
installed = self.list_models()
|
|
43
|
+
wanted = {self.model, self.model if ":" in self.model else self.model + ":latest"}
|
|
44
|
+
if not wanted & set(installed):
|
|
45
|
+
raise BackendError(f"model '{self.model}' is not installed in Ollama; run: ollama pull {self.model}")
|
|
46
|
+
|
|
47
|
+
def describe(self) -> dict[str, Any]:
|
|
48
|
+
info: dict[str, Any] = {"backend": self.name, "host": self.host}
|
|
49
|
+
try:
|
|
50
|
+
info["backend_version"] = self.version()
|
|
51
|
+
shown = self.show()
|
|
52
|
+
details = shown.get("details", {})
|
|
53
|
+
info["quant"] = details.get("quantization_level", "")
|
|
54
|
+
info["parameter_size"] = details.get("parameter_size", "")
|
|
55
|
+
info["family"] = details.get("family", "")
|
|
56
|
+
info["capabilities"] = shown.get("capabilities", [])
|
|
57
|
+
except BackendError:
|
|
58
|
+
pass
|
|
59
|
+
return info
|
|
60
|
+
|
|
61
|
+
# -------------------------------------------------------------------- chat
|
|
62
|
+
|
|
63
|
+
def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> BackendResponse:
|
|
64
|
+
payload: dict[str, Any] = {
|
|
65
|
+
"model": self.model,
|
|
66
|
+
"messages": messages,
|
|
67
|
+
"tools": tools,
|
|
68
|
+
"stream": False,
|
|
69
|
+
"options": self.options,
|
|
70
|
+
"keep_alive": self.keep_alive,
|
|
71
|
+
}
|
|
72
|
+
if self.think is not None:
|
|
73
|
+
payload["think"] = self.think
|
|
74
|
+
data = request_json("POST", f"{self.host}/api/chat", payload, self.timeout)
|
|
75
|
+
message = data.get("message") or {}
|
|
76
|
+
calls = []
|
|
77
|
+
for item in message.get("tool_calls") or []:
|
|
78
|
+
function = item.get("function") or {}
|
|
79
|
+
calls.append(RawToolCall(name=str(function.get("name", "")), arguments=function.get("arguments", {})))
|
|
80
|
+
return BackendResponse(
|
|
81
|
+
content=message.get("content") or "",
|
|
82
|
+
tool_calls=calls,
|
|
83
|
+
eval_tokens=int(data.get("eval_count") or 0),
|
|
84
|
+
eval_seconds=float(data.get("eval_duration") or 0) / 1e9,
|
|
85
|
+
prompt_tokens=int(data.get("prompt_eval_count") or 0),
|
|
86
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""OpenAI-compatible backend for llama.cpp `llama-server`, LM Studio, vLLM and similar servers.
|
|
2
|
+
|
|
3
|
+
Tool arguments arrive as JSON strings here, so this backend exercises the invalid-JSON path.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..models import BackendResponse, RawToolCall
|
|
12
|
+
from .base import Backend, BackendError, request_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_openai_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
16
|
+
"""Convert the runner's Ollama-style messages to the OpenAI chat format (adds tool call ids)."""
|
|
17
|
+
out: list[dict[str, Any]] = []
|
|
18
|
+
pending: list[str] = []
|
|
19
|
+
counter = 0
|
|
20
|
+
for message in messages:
|
|
21
|
+
if message["role"] == "assistant" and message.get("tool_calls"):
|
|
22
|
+
pending = []
|
|
23
|
+
calls = []
|
|
24
|
+
for item in message["tool_calls"]:
|
|
25
|
+
counter += 1
|
|
26
|
+
call_id = f"call_{counter}"
|
|
27
|
+
pending.append(call_id)
|
|
28
|
+
function = item["function"]
|
|
29
|
+
calls.append(
|
|
30
|
+
{
|
|
31
|
+
"id": call_id,
|
|
32
|
+
"type": "function",
|
|
33
|
+
"function": {"name": function["name"], "arguments": json.dumps(function.get("arguments", {}))},
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
out.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": calls})
|
|
37
|
+
elif message["role"] == "tool":
|
|
38
|
+
call_id = pending.pop(0) if pending else "call_0"
|
|
39
|
+
out.append({"role": "tool", "tool_call_id": call_id, "content": message["content"]})
|
|
40
|
+
else:
|
|
41
|
+
out.append({"role": message["role"], "content": message.get("content") or ""})
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OpenAICompatBackend(Backend):
|
|
46
|
+
name = "openai-compat"
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
model: str,
|
|
51
|
+
base_url: str = "http://localhost:8080",
|
|
52
|
+
options: dict[str, Any] | None = None,
|
|
53
|
+
timeout: float = 300.0,
|
|
54
|
+
):
|
|
55
|
+
self.model = model
|
|
56
|
+
self.base_url = base_url.rstrip("/")
|
|
57
|
+
self.options = dict(options or {})
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
|
|
60
|
+
def check(self) -> None:
|
|
61
|
+
request_json("GET", f"{self.base_url}/v1/models", None, self.timeout)
|
|
62
|
+
|
|
63
|
+
def describe(self) -> dict[str, Any]:
|
|
64
|
+
return {"backend": self.name, "host": self.base_url}
|
|
65
|
+
|
|
66
|
+
def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> BackendResponse:
|
|
67
|
+
payload: dict[str, Any] = {
|
|
68
|
+
"model": self.model,
|
|
69
|
+
"messages": to_openai_messages(messages),
|
|
70
|
+
"tools": tools,
|
|
71
|
+
"stream": False,
|
|
72
|
+
}
|
|
73
|
+
for key in ("temperature", "seed"):
|
|
74
|
+
if key in self.options:
|
|
75
|
+
payload[key] = self.options[key]
|
|
76
|
+
started = time.monotonic()
|
|
77
|
+
data = request_json("POST", f"{self.base_url}/v1/chat/completions", payload, self.timeout)
|
|
78
|
+
elapsed = time.monotonic() - started
|
|
79
|
+
try:
|
|
80
|
+
message = data["choices"][0]["message"]
|
|
81
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
82
|
+
raise BackendError("response has no choices[0].message") from exc
|
|
83
|
+
calls = []
|
|
84
|
+
for item in message.get("tool_calls") or []:
|
|
85
|
+
function = item.get("function") or {}
|
|
86
|
+
calls.append(RawToolCall(name=str(function.get("name", "")), arguments=function.get("arguments", "")))
|
|
87
|
+
usage = data.get("usage") or {}
|
|
88
|
+
timings = data.get("timings") or {}
|
|
89
|
+
eval_seconds = float(timings["predicted_ms"]) / 1000 if "predicted_ms" in timings else elapsed
|
|
90
|
+
return BackendResponse(
|
|
91
|
+
content=message.get("content") or "",
|
|
92
|
+
tool_calls=calls,
|
|
93
|
+
eval_tokens=int(usage.get("completion_tokens") or 0),
|
|
94
|
+
eval_seconds=eval_seconds,
|
|
95
|
+
prompt_tokens=int(usage.get("prompt_tokens") or 0),
|
|
96
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""A fake model that replays each task's reference solution, or fails in a chosen way.
|
|
2
|
+
|
|
3
|
+
It is used to validate the tasks, to test the harness without a real model, and for dry runs.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..models import BackendResponse, RawToolCall, Task
|
|
11
|
+
from .base import Backend, BackendError
|
|
12
|
+
|
|
13
|
+
PROFILES = (
|
|
14
|
+
"oracle",
|
|
15
|
+
"invalid_json",
|
|
16
|
+
"unknown_tool",
|
|
17
|
+
"wrong_tool",
|
|
18
|
+
"text_call",
|
|
19
|
+
"no_call",
|
|
20
|
+
"call_limit",
|
|
21
|
+
"bad_edit",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScriptedBackend(Backend):
|
|
26
|
+
name = "scripted"
|
|
27
|
+
|
|
28
|
+
def __init__(self, tasks: list[Task], profile: str = "oracle"):
|
|
29
|
+
if profile not in PROFILES:
|
|
30
|
+
raise ValueError(f"unknown profile '{profile}'; choose from {', '.join(PROFILES)}")
|
|
31
|
+
self.by_prompt = {task.prompt: task for task in tasks}
|
|
32
|
+
self.profile = profile
|
|
33
|
+
|
|
34
|
+
def describe(self) -> dict[str, Any]:
|
|
35
|
+
return {"backend": self.name, "profile": self.profile}
|
|
36
|
+
|
|
37
|
+
def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> BackendResponse:
|
|
38
|
+
prompt = next((m["content"] for m in messages if m["role"] == "user"), "")
|
|
39
|
+
task = self.by_prompt.get(prompt)
|
|
40
|
+
if task is None:
|
|
41
|
+
raise BackendError("scripted backend received a prompt that matches no task")
|
|
42
|
+
turn = sum(1 for m in messages if m["role"] == "assistant")
|
|
43
|
+
content, calls = self._step(task, turn)
|
|
44
|
+
return BackendResponse(content=content, tool_calls=calls, eval_tokens=8, eval_seconds=0.01, prompt_tokens=100)
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------ steps
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _oracle(task: Task, turn: int) -> tuple[str, list[RawToolCall]]:
|
|
50
|
+
if turn >= len(task.reference):
|
|
51
|
+
return "Done.", []
|
|
52
|
+
step = task.reference[turn]
|
|
53
|
+
if "calls" in step:
|
|
54
|
+
return "", [RawToolCall(c["tool"], dict(c.get("args", {}))) for c in step["calls"]]
|
|
55
|
+
return str(step["final"]), []
|
|
56
|
+
|
|
57
|
+
def _step(self, task: Task, turn: int) -> tuple[str, list[RawToolCall]]:
|
|
58
|
+
profile = self.profile
|
|
59
|
+
if profile == "oracle":
|
|
60
|
+
return self._oracle(task, turn)
|
|
61
|
+
if profile == "call_limit":
|
|
62
|
+
return "", [RawToolCall("list_dir", {"path": "."})]
|
|
63
|
+
if profile == "no_call":
|
|
64
|
+
return "I believe the answer is 42.", []
|
|
65
|
+
if profile == "bad_edit":
|
|
66
|
+
content, calls = self._oracle(task, turn)
|
|
67
|
+
for call in calls:
|
|
68
|
+
if call.name == "edit_file":
|
|
69
|
+
call.arguments = {**call.arguments, "old_text": "THIS TEXT IS NOT IN THE FILE"}
|
|
70
|
+
elif call.name == "apply_patch":
|
|
71
|
+
call.arguments = {"patch": "--- a/missing.py\n+++ b/missing.py\n@@ -1,1 +1,1 @@\n-x\n+y\n"}
|
|
72
|
+
return content, calls
|
|
73
|
+
if turn >= 1:
|
|
74
|
+
return "Done.", []
|
|
75
|
+
first = next((c for step in task.reference for c in step.get("calls", [])), None)
|
|
76
|
+
if first is None:
|
|
77
|
+
# The task needs no tools. A wrong_tool model calls one anyway; the others answer correctly.
|
|
78
|
+
if profile == "wrong_tool":
|
|
79
|
+
return "", [RawToolCall("list_dir", {})]
|
|
80
|
+
return self._oracle(task, turn)
|
|
81
|
+
tool, args = first["tool"], dict(first.get("args", {}))
|
|
82
|
+
if profile == "invalid_json":
|
|
83
|
+
return "", [RawToolCall(tool, '{"path": ')]
|
|
84
|
+
if profile == "unknown_tool":
|
|
85
|
+
return "", [RawToolCall("search_everything", {"query": "x"})]
|
|
86
|
+
if profile == "wrong_tool":
|
|
87
|
+
return "", [RawToolCall("list_dir", {})]
|
|
88
|
+
if profile == "text_call":
|
|
89
|
+
return json.dumps({"name": tool, "arguments": args}), []
|
|
90
|
+
raise AssertionError(profile) # pragma: no cover
|
toolcallbench/checks.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Deterministic scoring of one run. There is no AI judge: every verdict comes from plain code.
|
|
2
|
+
|
|
3
|
+
A run passes when Parse, Tool and Outcome all succeed. Arguments and Efficiency are reported separately.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .models import CallRecord, CheckResult, Task, Transcript
|
|
13
|
+
from .tools import run_trusted
|
|
14
|
+
|
|
15
|
+
EDIT_TOOLS = {"edit_file", "apply_patch"}
|
|
16
|
+
|
|
17
|
+
FAILURE_TYPES = (
|
|
18
|
+
"invalid_json",
|
|
19
|
+
"invalid_args",
|
|
20
|
+
"unknown_tool",
|
|
21
|
+
"text_tool_call",
|
|
22
|
+
"no_call",
|
|
23
|
+
"unneeded_call",
|
|
24
|
+
"call_limit",
|
|
25
|
+
"not_parallel",
|
|
26
|
+
"wrong_tool",
|
|
27
|
+
"too_few_calls",
|
|
28
|
+
"patch_failed",
|
|
29
|
+
"wrong_args",
|
|
30
|
+
"outcome_mismatch",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _norm_path(value: Any) -> str:
|
|
35
|
+
text = str(value).strip().replace("\\", "/")
|
|
36
|
+
while text.startswith("./"):
|
|
37
|
+
text = text[2:]
|
|
38
|
+
if len(text) > 1:
|
|
39
|
+
text = text.rstrip("/")
|
|
40
|
+
return text or "."
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _norm(key: str, value: Any) -> str:
|
|
44
|
+
return _norm_path(value) if key == "path" else str(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _value_matches(spec: Any, actual: Any, key: str) -> bool:
|
|
48
|
+
got = _norm(key, actual)
|
|
49
|
+
if isinstance(spec, dict):
|
|
50
|
+
if "contains" in spec:
|
|
51
|
+
return str(spec["contains"]) in got
|
|
52
|
+
if "regex" in spec:
|
|
53
|
+
return re.search(spec["regex"], got) is not None
|
|
54
|
+
if "one_of" in spec:
|
|
55
|
+
return got in [_norm(key, v) for v in spec["one_of"]]
|
|
56
|
+
raise ValueError(f"unknown argument matcher: {sorted(spec)}")
|
|
57
|
+
return got == _norm(key, spec)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _tools(req: dict[str, Any]) -> list[str]:
|
|
61
|
+
tool = req["tool"]
|
|
62
|
+
return list(tool) if isinstance(tool, list) else [tool]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tool_matches(req: dict[str, Any], call: CallRecord) -> bool:
|
|
66
|
+
return call.valid and call.tool in _tools(req)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _args_match(req: dict[str, Any], call: CallRecord) -> bool:
|
|
70
|
+
args = call.arguments if isinstance(call.arguments, dict) else {}
|
|
71
|
+
return all(key in args and _value_matches(spec, args[key], key) for key, spec in (req.get("args") or {}).items())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _assign(reqs: list[dict[str, Any]], calls: list[CallRecord], ordered: bool, start: int = 0, used: frozenset = frozenset()):
|
|
75
|
+
"""Match each requirement to a distinct call by tool name. Returns the call positions or None."""
|
|
76
|
+
if not reqs:
|
|
77
|
+
return []
|
|
78
|
+
first, rest = reqs[0], reqs[1:]
|
|
79
|
+
for position in range(start if ordered else 0, len(calls)):
|
|
80
|
+
if position in used or not _tool_matches(first, calls[position]):
|
|
81
|
+
continue
|
|
82
|
+
tail = _assign(rest, calls, ordered, position + 1, used | {position})
|
|
83
|
+
if tail is not None:
|
|
84
|
+
return [position, *tail]
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parallel_ok(reqs: list[dict[str, Any]], calls: list[CallRecord]) -> bool:
|
|
89
|
+
turns: dict[int, list[CallRecord]] = {}
|
|
90
|
+
for call in calls:
|
|
91
|
+
turns.setdefault(call.turn, []).append(call)
|
|
92
|
+
return any(_assign(reqs, group, False) is not None for group in turns.values())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _check_outcome(task: Task, tr: Transcript, root: Path, fixture_dir: Path, details: list[str]) -> bool:
|
|
96
|
+
exp = task.expect
|
|
97
|
+
ok = True
|
|
98
|
+
|
|
99
|
+
def fail(message: str) -> None:
|
|
100
|
+
nonlocal ok
|
|
101
|
+
ok = False
|
|
102
|
+
details.append(message)
|
|
103
|
+
|
|
104
|
+
answer = tr.final_text or ""
|
|
105
|
+
for needle in exp.get("answer_contains", []):
|
|
106
|
+
if needle.lower() not in answer.lower():
|
|
107
|
+
fail(f"answer does not contain {needle!r}")
|
|
108
|
+
for pattern in exp.get("answer_regex", []):
|
|
109
|
+
if not re.search(pattern, answer, re.IGNORECASE):
|
|
110
|
+
fail(f"answer does not match /{pattern}/")
|
|
111
|
+
for rel in exp.get("files_exist", []):
|
|
112
|
+
if not (root / rel).is_file():
|
|
113
|
+
fail(f"expected file {rel} does not exist")
|
|
114
|
+
for rel, spec in (exp.get("final_files") or {}).items():
|
|
115
|
+
path = root / rel
|
|
116
|
+
if not path.is_file():
|
|
117
|
+
fail(f"expected file {rel} does not exist")
|
|
118
|
+
continue
|
|
119
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
120
|
+
for text in spec.get("contains", []):
|
|
121
|
+
if text not in content:
|
|
122
|
+
fail(f"{rel} does not contain {text!r}")
|
|
123
|
+
for text in spec.get("not_contains", []):
|
|
124
|
+
if text in content:
|
|
125
|
+
fail(f"{rel} still contains {text!r}")
|
|
126
|
+
if spec.get("unchanged") and content != (fixture_dir / rel).read_text(encoding="utf-8"):
|
|
127
|
+
fail(f"{rel} was changed but must stay as it was")
|
|
128
|
+
for item in exp.get("command", []):
|
|
129
|
+
try:
|
|
130
|
+
proc = run_trusted(item["argv"], root)
|
|
131
|
+
except subprocess.TimeoutExpired:
|
|
132
|
+
fail(f"check command timed out: {item['argv']}")
|
|
133
|
+
continue
|
|
134
|
+
want = item.get("exit_code", 0)
|
|
135
|
+
if proc.returncode != want:
|
|
136
|
+
tail = (proc.stdout + proc.stderr).strip()[-300:]
|
|
137
|
+
fail(f"check command exited {proc.returncode}, wanted {want}: {tail}")
|
|
138
|
+
return ok
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def evaluate(task: Task, tr: Transcript, root: Path, fixture_dir: Path, default_max_calls: int = 12) -> CheckResult:
|
|
142
|
+
exp = task.expect
|
|
143
|
+
details: list[str] = []
|
|
144
|
+
calls = tr.calls
|
|
145
|
+
valid = [c for c in calls if c.valid]
|
|
146
|
+
|
|
147
|
+
# Parse: every call must be well formed and name a real tool.
|
|
148
|
+
parse_type = ""
|
|
149
|
+
if tr.text_tool_call:
|
|
150
|
+
parse_type = "text_tool_call"
|
|
151
|
+
else:
|
|
152
|
+
bad = next((c for c in calls if not c.valid), None)
|
|
153
|
+
if bad:
|
|
154
|
+
parse_type = bad.error_kind
|
|
155
|
+
parse_ok = parse_type == ""
|
|
156
|
+
if parse_type:
|
|
157
|
+
details.append(f"parse: {parse_type}")
|
|
158
|
+
|
|
159
|
+
# Tool: the right tools, in the right amount.
|
|
160
|
+
reqs = exp.get("calls") or []
|
|
161
|
+
ordered = bool(exp.get("ordered"))
|
|
162
|
+
max_calls = exp.get("max_calls", default_max_calls)
|
|
163
|
+
min_calls = exp.get("min_calls", len(reqs))
|
|
164
|
+
tool_type = ""
|
|
165
|
+
if tr.hit_limit:
|
|
166
|
+
tool_type = "call_limit"
|
|
167
|
+
elif exp.get("no_tool_calls"):
|
|
168
|
+
if calls:
|
|
169
|
+
tool_type = "unneeded_call"
|
|
170
|
+
elif not calls:
|
|
171
|
+
tool_type = "no_call" if min_calls > 0 else ""
|
|
172
|
+
elif len(calls) > max_calls:
|
|
173
|
+
tool_type = "call_limit"
|
|
174
|
+
elif len(calls) < min_calls:
|
|
175
|
+
tool_type = "too_few_calls"
|
|
176
|
+
elif any(c.tool in exp.get("forbidden_tools", []) for c in calls):
|
|
177
|
+
tool_type = "wrong_tool"
|
|
178
|
+
elif reqs and _assign(reqs, valid, ordered) is None:
|
|
179
|
+
tool_type = "wrong_tool"
|
|
180
|
+
elif exp.get("parallel") and not _parallel_ok(reqs, valid):
|
|
181
|
+
tool_type = "not_parallel"
|
|
182
|
+
tool_ok = tool_type == ""
|
|
183
|
+
if tool_type:
|
|
184
|
+
details.append(f"tool: {tool_type}")
|
|
185
|
+
|
|
186
|
+
# Arguments: reported separately from pass/fail.
|
|
187
|
+
args_ok = True
|
|
188
|
+
for req in reqs:
|
|
189
|
+
if req.get("args") and not any(_tool_matches(req, c) and _args_match(req, c) for c in valid):
|
|
190
|
+
args_ok = False
|
|
191
|
+
details.append(f"arguments: no {_tools(req)} call matched {req['args']}")
|
|
192
|
+
|
|
193
|
+
# Outcome: the answer text and the final state of the repository.
|
|
194
|
+
outcome_ok = _check_outcome(task, tr, root, fixture_dir, details)
|
|
195
|
+
|
|
196
|
+
if not parse_ok:
|
|
197
|
+
failure = parse_type
|
|
198
|
+
elif not tool_ok:
|
|
199
|
+
failure = tool_type
|
|
200
|
+
elif not outcome_ok:
|
|
201
|
+
if any(c.tool in EDIT_TOOLS and c.valid and not c.ok for c in calls):
|
|
202
|
+
failure = "patch_failed"
|
|
203
|
+
elif not args_ok:
|
|
204
|
+
failure = "wrong_args"
|
|
205
|
+
else:
|
|
206
|
+
failure = "outcome_mismatch"
|
|
207
|
+
else:
|
|
208
|
+
failure = "ok"
|
|
209
|
+
|
|
210
|
+
baseline = task.baseline_calls
|
|
211
|
+
if baseline > 0:
|
|
212
|
+
efficiency: float | None = round(len(calls) / baseline, 2)
|
|
213
|
+
else:
|
|
214
|
+
efficiency = 1.0 if not calls else None
|
|
215
|
+
|
|
216
|
+
return CheckResult(
|
|
217
|
+
parse_ok=parse_ok,
|
|
218
|
+
tool_ok=tool_ok,
|
|
219
|
+
args_ok=args_ok,
|
|
220
|
+
outcome_ok=outcome_ok,
|
|
221
|
+
passed=parse_ok and tool_ok and outcome_ok,
|
|
222
|
+
failure_type=failure,
|
|
223
|
+
efficiency=efficiency,
|
|
224
|
+
details=details,
|
|
225
|
+
)
|