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.
- agentic_mcp_solver-4.2.0.dist-info/METADATA +318 -0
- agentic_mcp_solver-4.2.0.dist-info/RECORD +22 -0
- agentic_mcp_solver-4.2.0.dist-info/WHEEL +4 -0
- agentic_mcp_solver-4.2.0.dist-info/entry_points.txt +5 -0
- agentic_mcp_solver-4.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_solver/__init__.py +20 -0
- mcp_solver/agent/__init__.py +1 -0
- mcp_solver/agent/cli.py +496 -0
- mcp_solver/agent/harness.py +258 -0
- mcp_solver/agent/mcp_server.py +508 -0
- mcp_solver/agent/runfolder.py +159 -0
- mcp_solver/helpers/__init__.py +7 -0
- mcp_solver/helpers/maxsat.py +79 -0
- mcp_solver/helpers/pysat.py +346 -0
- mcp_solver/helpers/z3.py +213 -0
- mcp_solver/templates/__init__.py +22 -0
- mcp_solver/templates/clingo.md +1305 -0
- mcp_solver/templates/cpmpy.md +124 -0
- mcp_solver/templates/didp.md +270 -0
- mcp_solver/templates/maxsat.md +192 -0
- mcp_solver/templates/pysat.md +186 -0
- mcp_solver/templates/z3.md +165 -0
|
@@ -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())
|