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.
Files changed (64) hide show
  1. toolcallbench/__init__.py +3 -0
  2. toolcallbench/__main__.py +6 -0
  3. toolcallbench/backends/__init__.py +43 -0
  4. toolcallbench/backends/base.py +42 -0
  5. toolcallbench/backends/ollama.py +86 -0
  6. toolcallbench/backends/openai_compat.py +96 -0
  7. toolcallbench/backends/scripted.py +90 -0
  8. toolcallbench/checks.py +225 -0
  9. toolcallbench/cli.py +436 -0
  10. toolcallbench/config.py +99 -0
  11. toolcallbench/data/configs/m4-16gb.yaml +22 -0
  12. toolcallbench/data/configs/my-models.yaml +19 -0
  13. toolcallbench/data/configs/quant-sweep-qwen3-8b.yaml +17 -0
  14. toolcallbench/data/configs/smoke-scripted.yaml +6 -0
  15. toolcallbench/data/fixtures/repo/README.md +17 -0
  16. toolcallbench/data/fixtures/repo/config/settings.json +7 -0
  17. toolcallbench/data/fixtures/repo/data/books.csv +7 -0
  18. toolcallbench/data/fixtures/repo/data/members.csv +4 -0
  19. toolcallbench/data/fixtures/repo/pytest.ini +3 -0
  20. toolcallbench/data/fixtures/repo/scripts/report.py +23 -0
  21. toolcallbench/data/fixtures/repo/src/shelf/__init__.py +3 -0
  22. toolcallbench/data/fixtures/repo/src/shelf/catalog.py +33 -0
  23. toolcallbench/data/fixtures/repo/src/shelf/fees.py +20 -0
  24. toolcallbench/data/fixtures/repo/src/shelf/loans.py +46 -0
  25. toolcallbench/data/fixtures/repo/src/shelf/models.py +30 -0
  26. toolcallbench/data/fixtures/repo/src/shelf/settings.py +12 -0
  27. toolcallbench/data/fixtures/repo/src/shelf/utils.py +19 -0
  28. toolcallbench/data/fixtures/repo/tests/test_catalog.py +19 -0
  29. toolcallbench/data/fixtures/repo/tests/test_fees.py +16 -0
  30. toolcallbench/data/fixtures/repo/tests/test_loans.py +32 -0
  31. toolcallbench/data/fixtures/repo/tests/test_utils.py +14 -0
  32. toolcallbench/data/tasks/chain-01.yaml +27 -0
  33. toolcallbench/data/tasks/chain-02.yaml +20 -0
  34. toolcallbench/data/tasks/chain-03.yaml +19 -0
  35. toolcallbench/data/tasks/chain-04.yaml +38 -0
  36. toolcallbench/data/tasks/edit-01.yaml +23 -0
  37. toolcallbench/data/tasks/edit-02.yaml +19 -0
  38. toolcallbench/data/tasks/edit-03.yaml +32 -0
  39. toolcallbench/data/tasks/edit-04.yaml +23 -0
  40. toolcallbench/data/tasks/edit-05.yaml +23 -0
  41. toolcallbench/data/tasks/failure-01.yaml +10 -0
  42. toolcallbench/data/tasks/failure-02.yaml +20 -0
  43. toolcallbench/data/tasks/failure-03.yaml +20 -0
  44. toolcallbench/data/tasks/read-01.yaml +15 -0
  45. toolcallbench/data/tasks/read-02.yaml +15 -0
  46. toolcallbench/data/tasks/read-03.yaml +16 -0
  47. toolcallbench/data/tasks/read-04.yaml +15 -0
  48. toolcallbench/data/tasks/read-05.yaml +16 -0
  49. toolcallbench/data/tasks/run-01.yaml +15 -0
  50. toolcallbench/data/tasks/run-02.yaml +15 -0
  51. toolcallbench/data/tasks/run-03.yaml +15 -0
  52. toolcallbench/models.py +79 -0
  53. toolcallbench/patching.py +130 -0
  54. toolcallbench/report.py +197 -0
  55. toolcallbench/runner.py +90 -0
  56. toolcallbench/store.py +225 -0
  57. toolcallbench/tasks.py +155 -0
  58. toolcallbench/tools.py +306 -0
  59. toolcallbench-1.0.0.dist-info/METADATA +113 -0
  60. toolcallbench-1.0.0.dist-info/RECORD +64 -0
  61. toolcallbench-1.0.0.dist-info/WHEEL +5 -0
  62. toolcallbench-1.0.0.dist-info/entry_points.txt +2 -0
  63. toolcallbench-1.0.0.dist-info/licenses/LICENSE +21 -0
  64. toolcallbench-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """ToolCallBench: measure how reliably local LLMs call tools on your own repo and hardware."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -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
@@ -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
+ )