agentic-mcp-solver 4.2.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.
@@ -0,0 +1,258 @@
1
+ """Benchmark harness: run test problems through the solving CLI and validate.
2
+
3
+ Each problem is solved in its own subprocess (the engine does not support
4
+ concurrent solves in one process), its JSON solution is piped into the
5
+ problem's ``*_ground_truth.py`` validator, and one result row per run is
6
+ appended to ``results.jsonl`` in the output directory.
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from mcp_solver.templates import SOLVERS
19
+
20
+
21
+ def discover(problems_root: Path, solvers: list[str]) -> list[tuple[str, Path]]:
22
+ """Return (solver, problem_md) pairs for every non-smoke problem."""
23
+ found = []
24
+ for solver in solvers:
25
+ directory = problems_root / solver
26
+ if not directory.is_dir():
27
+ continue
28
+ for md in sorted(directory.glob("*.md")):
29
+ if md.name != "test.md":
30
+ found.append((solver, md))
31
+ return found
32
+
33
+
34
+ def run_one(
35
+ solver: str,
36
+ problem_md: Path,
37
+ args: argparse.Namespace,
38
+ out_root: Path,
39
+ run_idx: int,
40
+ ) -> dict:
41
+ """Solve one problem in a CLI subprocess and validate its output."""
42
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
43
+ workdir = out_root / solver / f"{problem_md.stem}_{stamp}_r{run_idx}"
44
+ workdir.mkdir(parents=True, exist_ok=True)
45
+ stats_file = workdir / "stats.json"
46
+
47
+ cmd = [
48
+ sys.executable,
49
+ "-m",
50
+ "mcp_solver.agent.cli",
51
+ solver,
52
+ "--problem",
53
+ str(problem_md),
54
+ "--workdir",
55
+ str(workdir),
56
+ "--model",
57
+ args.model,
58
+ "--stats-json",
59
+ str(stats_file),
60
+ "-q",
61
+ ]
62
+ if args.dev is not None:
63
+ cmd.append("--dev")
64
+ if args.dev != "auto":
65
+ cmd.append(args.dev)
66
+ if args.step_limit:
67
+ cmd += ["--step-limit", str(args.step_limit)]
68
+
69
+ row = {
70
+ "solver": solver,
71
+ "problem": problem_md.stem,
72
+ "model": args.model,
73
+ "timestamp": stamp,
74
+ "workdir": str(workdir),
75
+ "dev": args.dev,
76
+ }
77
+ start = time.monotonic()
78
+ try:
79
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=args.timeout)
80
+ row["exit_code"] = proc.returncode
81
+ row["solution"] = proc.stdout.strip()
82
+ except subprocess.TimeoutExpired:
83
+ row["exit_code"] = -1
84
+ row["solution"] = ""
85
+ row["error"] = f"timeout after {args.timeout}s"
86
+ row["wall_seconds"] = round(time.monotonic() - start, 1)
87
+
88
+ if stats_file.is_file():
89
+ try:
90
+ stats = json.loads(stats_file.read_text())
91
+ except (json.JSONDecodeError, OSError):
92
+ # A run killed mid-write can leave a truncated stats file; one bad
93
+ # run must not abort the whole benchmark.
94
+ stats = {}
95
+ row["total_tokens"] = stats.get("token_consumption", {}).get("total_tokens")
96
+ row["exec_calls"] = stats.get("tool_usage", {}).get("python_exec", 0)
97
+ if stats.get("step_limit_reached"):
98
+ row["step_limit_reached"] = True
99
+
100
+ validator = problem_md.with_name(f"{problem_md.stem}_ground_truth.py")
101
+ if not validator.is_file():
102
+ row["valid"] = None
103
+ row["message"] = "no validator"
104
+ elif not row["solution"]:
105
+ row["valid"] = False
106
+ row["message"] = "no solution output"
107
+ else:
108
+ try:
109
+ check = subprocess.run(
110
+ [sys.executable, str(validator)],
111
+ input=row["solution"],
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=120,
115
+ )
116
+ except subprocess.TimeoutExpired:
117
+ row["valid"] = False
118
+ row["message"] = "validator timed out after 120s"
119
+ return row
120
+ out = check.stdout.strip()
121
+ verdict = None
122
+ for candidate in (out, out.splitlines()[-1] if out else ""):
123
+ try:
124
+ verdict = json.loads(candidate)
125
+ break
126
+ except json.JSONDecodeError:
127
+ continue
128
+ if isinstance(verdict, dict):
129
+ row["valid"] = bool(verdict.get("valid"))
130
+ row["message"] = verdict.get("message", "")
131
+ else:
132
+ row["valid"] = False
133
+ row["message"] = f"validator produced no verdict: {check.stderr[:200]}"
134
+ return row
135
+
136
+
137
+ def print_summary(rows: list[dict]) -> None:
138
+ """Print a per-solver summary table to stderr."""
139
+ by_solver: dict[str, list[dict]] = {}
140
+ for row in rows:
141
+ by_solver.setdefault(row["solver"], []).append(row)
142
+ print("\nsolver valid/run tokens(avg) exec(avg) wall(avg)", file=sys.stderr)
143
+ for solver, group in sorted(by_solver.items()):
144
+ n = len(group)
145
+ valid = sum(1 for r in group if r.get("valid"))
146
+ tokens = [r["total_tokens"] for r in group if r.get("total_tokens") is not None]
147
+ execs = [r["exec_calls"] for r in group if "exec_calls" in r]
148
+ walls = [r["wall_seconds"] for r in group]
149
+ avg = lambda xs: sum(xs) / len(xs) if xs else 0 # noqa: E731
150
+ print(
151
+ f"{solver:<8} {valid}/{n:<9} {avg(tokens):>11,.0f} {avg(execs):>11.1f}"
152
+ f" {avg(walls):>10.1f}s",
153
+ file=sys.stderr,
154
+ )
155
+ failures = [r for r in rows if r.get("valid") is False]
156
+ for r in failures:
157
+ print(
158
+ f" FAIL {r['solver']}/{r['problem']}: {r.get('message', '')[:120]}",
159
+ file=sys.stderr,
160
+ )
161
+
162
+
163
+ def main(argv: list[str] | None = None) -> int:
164
+ parser = argparse.ArgumentParser(
165
+ prog="mcp-solver-bench",
166
+ description="Run test problems through the solving CLI and validate results.",
167
+ )
168
+ parser.add_argument(
169
+ "solvers",
170
+ nargs="*",
171
+ default=list(SOLVERS),
172
+ help=f"solvers to benchmark (default: all of {', '.join(SOLVERS)})",
173
+ )
174
+ parser.add_argument(
175
+ "--problems",
176
+ metavar="DIR",
177
+ default="tests/problems",
178
+ help="root directory of per-solver problem folders (default: %(default)s)",
179
+ )
180
+ parser.add_argument("--model", default="gpt56terra", help="agent model name")
181
+ parser.add_argument("--runs", type=int, default=1, help="iterations per problem")
182
+ parser.add_argument("--jobs", type=int, default=4, help="parallel solves")
183
+ parser.add_argument(
184
+ "--timeout",
185
+ type=int,
186
+ default=900,
187
+ help="safety backstop in seconds per solve (the operative bound is --step-limit)",
188
+ )
189
+ parser.add_argument(
190
+ "--step-limit",
191
+ type=int,
192
+ default=30,
193
+ help="maximum agent steps per solve (default: %(default)s)",
194
+ )
195
+ parser.add_argument(
196
+ "--dev",
197
+ nargs="?",
198
+ const="auto",
199
+ default=None,
200
+ metavar="PATH",
201
+ help="dev mode: pass through to the CLI so helpers and templates come"
202
+ " from a local checkout (bare --dev auto-detects; default: CLI decides)",
203
+ )
204
+ parser.add_argument(
205
+ "--out",
206
+ metavar="DIR",
207
+ default=None,
208
+ help="output directory (default: bench_results/<timestamp>)",
209
+ )
210
+ args = parser.parse_args(argv)
211
+
212
+ solvers = args.solvers
213
+ unknown = set(solvers) - set(SOLVERS)
214
+ if unknown:
215
+ parser.error(f"unknown solver(s): {', '.join(sorted(unknown))}")
216
+
217
+ problems = discover(Path(args.problems), solvers)
218
+ if not problems:
219
+ parser.error(f"no problems found under {args.problems} for {solvers}")
220
+
221
+ out_root = Path(
222
+ args.out or f"bench_results/{datetime.now().strftime('%Y%m%d_%H%M%S')}"
223
+ )
224
+ out_root.mkdir(parents=True, exist_ok=True)
225
+ results_file = out_root / "results.jsonl"
226
+
227
+ jobs = [(s, p, i) for s, p in problems for i in range(args.runs)]
228
+ print(
229
+ f"mcp-solver-bench: {len(jobs)} runs ({len(problems)} problems x {args.runs}),"
230
+ f" model {args.model}, {args.jobs} parallel -> {out_root}",
231
+ file=sys.stderr,
232
+ )
233
+
234
+ rows = []
235
+ with ThreadPoolExecutor(max_workers=args.jobs) as pool:
236
+ futures = {
237
+ pool.submit(run_one, s, p, args, out_root, i): (s, p) for s, p, i in jobs
238
+ }
239
+ for future in as_completed(futures):
240
+ row = future.result()
241
+ rows.append(row)
242
+ with results_file.open("a") as fh:
243
+ fh.write(json.dumps(row) + "\n")
244
+ status = {True: "ok", False: "FAIL", None: "?"}[row.get("valid")]
245
+ print(
246
+ f"[{len(rows)}/{len(jobs)}] {status:<4} {row['solver']}/{row['problem']}"
247
+ f" ({row['wall_seconds']}s)",
248
+ file=sys.stderr,
249
+ )
250
+
251
+ print_summary(rows)
252
+ print(str(results_file))
253
+ # valid=None means "no validator", which is not a failure.
254
+ return 0 if all(r.get("valid") is not False for r in rows) else 1
255
+
256
+
257
+ if __name__ == "__main__":
258
+ sys.exit(main())