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,496 @@
1
+ """mcp-solver command-line interface (v4).
2
+
3
+ Usage:
4
+ mcp-solver <solver> [task ...] [options]
5
+
6
+ Runs an mcp-minion agent that writes a solver program for a constraint
7
+ problem, working against the agentic-python-coder ``ipython_mcp`` kernel
8
+ server over stdio. The agent must submit its final program via the server's
9
+ ``submit_code`` tool; the CLI persists that submission to
10
+ ``<basename>_code.py``, re-executes it in a fresh ``uv`` kernel, and prints
11
+ its solution (JSON) to stdout.
12
+ """
13
+
14
+ import argparse
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import mcp_solver
26
+ from mcp_solver.templates import SOLVERS, get_template
27
+
28
+ # Re-execute the submitted program under the same interpreter version that
29
+ # ran the agent's kernel, so in-kernel verification carries over.
30
+ PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
31
+
32
+ # Solver libraries injected into the solve-time kernel, per backend.
33
+ # pypblib backs pysat.pb.PBEnc (pseudo-Boolean encodings — budgets,
34
+ # capacities); without it PBEnc raises at import time.
35
+ SOLVER_PACKAGES: dict[str, list[str]] = {
36
+ "pysat": ["python-sat", "pypblib"],
37
+ "maxsat": ["python-sat", "pypblib"],
38
+ "z3": ["z3-solver"],
39
+ "cpmpy": ["cpmpy"],
40
+ "clingo": ["clingo"],
41
+ # didppy is bounded: the didp template documents the 0.10.x API, which
42
+ # was verified empirically; lift the cap only after re-testing.
43
+ "didp": ["didppy>=0.11.1,<0.12"],
44
+ }
45
+
46
+
47
+ def _local_checkout() -> str | None:
48
+ """Return the repo root when mcp_solver runs from a source checkout.
49
+
50
+ An editable install resolves ``mcp_solver`` to ``<root>/src/mcp_solver``;
51
+ a regular install resolves it inside site-packages, where the checkout
52
+ markers are absent.
53
+ """
54
+ root = Path(mcp_solver.__file__).resolve().parents[2]
55
+ if (root / "pyproject.toml").is_file() and (root / "src" / "mcp_solver").is_dir():
56
+ return str(root)
57
+ return None
58
+
59
+
60
+ def _is_unpublishable(version: str) -> bool:
61
+ """Return True when *version* looks like a pre-release/dev build.
62
+
63
+ Published releases are plain numeric (e.g. ``4.0.0``). Anything else —
64
+ an alphabetic marker (``a``/``b``/``rc``/``dev`` per PEP 440) or a local
65
+ segment (``4.0.0+1``) — is not on PyPI, so a PyPI pin to it would fail.
66
+ """
67
+ return re.fullmatch(r"\d+(\.\d+)*", version) is None
68
+
69
+
70
+ def resolve_dev_path(
71
+ args: argparse.Namespace, parser: argparse.ArgumentParser
72
+ ) -> str | None:
73
+ """Return the effective dev-mode path (a source checkout root) or None.
74
+
75
+ Dev mode means "everything Python-related comes from the local repo":
76
+ the injected helper library and the solver templates. Resolution order:
77
+
78
+ 1. ``--no-dev`` → None (published behavior), overrides everything.
79
+ 2. ``--dev PATH`` → that path.
80
+ 3. ``--dev`` (no path) → auto-detected checkout, or a parser error.
81
+ 4. ``$MCP_SOLVER_DEV`` set to ``1``/``true``/``auto`` → auto-detect
82
+ (error if none); any other non-empty value → treat as a path.
83
+ 5. Nothing set → auto-default to a detected checkout (may be None).
84
+ """
85
+ if args.no_dev:
86
+ return None
87
+ if args.dev is not None:
88
+ if args.dev == "auto":
89
+ checkout = _local_checkout()
90
+ if checkout is None:
91
+ parser.error("no source checkout detected; pass --dev PATH")
92
+ return checkout
93
+ return args.dev
94
+ env = os.environ.get("MCP_SOLVER_DEV")
95
+ if env:
96
+ if env.lower() in ("1", "true", "auto"):
97
+ checkout = _local_checkout()
98
+ if checkout is None:
99
+ parser.error("no source checkout detected; pass --dev PATH")
100
+ return checkout
101
+ return env
102
+ return _local_checkout()
103
+
104
+
105
+ def helpers_package(dev_path: str | None = None) -> str:
106
+ """Return the ``--with`` spec for the mcp-solver helper library.
107
+
108
+ In dev mode (*dev_path* given) the helpers come from that local
109
+ checkout; otherwise from a PyPI pin to the installed version.
110
+ """
111
+ if dev_path:
112
+ return dev_path
113
+ return f"{mcp_solver.DIST_NAME}=={mcp_solver.__version__}"
114
+
115
+
116
+ def build_with_packages(solver: str, dev_path: str | None = None) -> list[str]:
117
+ """Assemble the ``--with`` package set for a solver run.
118
+
119
+ The solver library comes first, then the mcp-solver helpers. Only the
120
+ pysat/maxsat/z3 templates reference the helpers, but they are injected
121
+ for every backend for uniformity.
122
+ """
123
+ return [*SOLVER_PACKAGES[solver], helpers_package(dev_path)]
124
+
125
+
126
+ def _model_params(config: dict[str, Any]) -> dict[str, Any]:
127
+ """Translate a model-alias config dict into OpenRouter API parameters.
128
+
129
+ Mirrors the engine's rules: models flagged ``no_sampling_params`` accept
130
+ only ``max_tokens``; otherwise the standard sampling knobs pass through.
131
+ (``top_k``/``provider``/``reasoning`` need ``extra_body``, which the
132
+ minion agent reserves, so they are not forwarded.)
133
+ """
134
+ params: dict[str, Any] = {}
135
+ if config.get("no_sampling_params"):
136
+ if "max_tokens" in config:
137
+ params["max_tokens"] = config["max_tokens"]
138
+ return params
139
+ for key in (
140
+ "temperature",
141
+ "max_tokens",
142
+ "top_p",
143
+ "frequency_penalty",
144
+ "presence_penalty",
145
+ ):
146
+ if key in config:
147
+ params[key] = config[key]
148
+ return params
149
+
150
+
151
+ def resolve_model(name: str) -> tuple[str, dict[str, Any]]:
152
+ """Resolve a model name to ``(openrouter_id, api_params)``.
153
+
154
+ A name containing ``/`` is already an OpenRouter model ID and passes
155
+ through unchanged. Anything else is an alias looked up in the engine's
156
+ bundled ``models/<name>.json`` (e.g. ``gpt56terra``).
157
+
158
+ Raises ValueError for an unknown alias.
159
+ """
160
+ if "/" in name:
161
+ return name, {}
162
+ from importlib.resources import files
163
+
164
+ try:
165
+ text = (files("agentic_python_coder") / "models" / f"{name}.json").read_text(
166
+ encoding="utf-8"
167
+ )
168
+ except (ModuleNotFoundError, FileNotFoundError) as exc:
169
+ raise ValueError(
170
+ f"unknown model alias {name!r}; pass a full OpenRouter ID"
171
+ " (e.g. openai/gpt-5.6-terra) or install the engine for aliases"
172
+ ) from exc
173
+ config = json.loads(text)
174
+ return config["path"], _model_params(config)
175
+
176
+
177
+ def find_api_key() -> str | None:
178
+ """Return the OpenRouter API key, or None.
179
+
180
+ Checked in order: the environment, then the dotenv files used by the
181
+ engine (``~/.config/coder/.env``) and by mcp-minion (``~/.mcp-minion``).
182
+ """
183
+ key = os.environ.get("OPENROUTER_API_KEY")
184
+ if key:
185
+ return key
186
+ from dotenv import dotenv_values
187
+
188
+ for dotfile in (
189
+ Path.home() / ".config" / "coder" / ".env",
190
+ Path.home() / ".mcp-minion",
191
+ ):
192
+ if dotfile.is_file():
193
+ key = dotenv_values(dotfile).get("OPENROUTER_API_KEY")
194
+ if key:
195
+ return key
196
+ return None
197
+
198
+
199
+ def build_parser() -> argparse.ArgumentParser:
200
+ """Construct the argument parser."""
201
+ parser = argparse.ArgumentParser(
202
+ prog="mcp-solver",
203
+ description="Solve a constraint problem with an LLM coding agent.",
204
+ )
205
+ parser.add_argument(
206
+ "solver",
207
+ choices=SOLVERS,
208
+ help="constraint solver backend",
209
+ )
210
+ parser.add_argument(
211
+ "task",
212
+ nargs="*",
213
+ help="task text (words are joined with spaces)",
214
+ )
215
+ parser.add_argument(
216
+ "--problem",
217
+ metavar="FILE",
218
+ help="read the task from a markdown file instead of positional text",
219
+ )
220
+ parser.add_argument(
221
+ "--model",
222
+ default="gpt56terra",
223
+ help="agent model name (default: %(default)s)",
224
+ )
225
+ parser.add_argument(
226
+ "--workdir",
227
+ metavar="DIR",
228
+ default=None,
229
+ help="working directory for the run (default: current directory)",
230
+ )
231
+ parser.add_argument(
232
+ "--step-limit",
233
+ type=int,
234
+ default=30,
235
+ metavar="N",
236
+ help="maximum agent steps before stopping (default: %(default)s)",
237
+ )
238
+ parser.add_argument(
239
+ "-q",
240
+ "--quiet",
241
+ action="store_true",
242
+ help="suppress engine progress output",
243
+ )
244
+ dev_group = parser.add_mutually_exclusive_group()
245
+ dev_group.add_argument(
246
+ "--dev",
247
+ nargs="?",
248
+ const="auto",
249
+ default=None,
250
+ metavar="PATH",
251
+ help="dev mode: take helpers and templates from a local checkout"
252
+ " (bare --dev auto-detects a source checkout; auto-on inside one)",
253
+ )
254
+ dev_group.add_argument(
255
+ "--no-dev",
256
+ action="store_true",
257
+ help="disable dev mode; use the published (PyPI-pinned) helpers",
258
+ )
259
+ parser.add_argument(
260
+ "--stats-json",
261
+ metavar="FILE",
262
+ default=None,
263
+ help="write the engine run statistics to FILE as JSON",
264
+ )
265
+ return parser
266
+
267
+
268
+ def resolve_task(
269
+ args: argparse.Namespace, parser: argparse.ArgumentParser
270
+ ) -> tuple[str, str]:
271
+ """Return (task_text, task_basename) enforcing task/problem exclusivity.
272
+
273
+ Exactly one of positional task text or ``--problem`` must be given.
274
+ """
275
+ task_text = " ".join(args.task).strip()
276
+ if args.problem and task_text:
277
+ parser.error("provide task text OR --problem, not both")
278
+ if not args.problem and not task_text:
279
+ parser.error("provide task text or --problem <file.md>")
280
+ if args.problem:
281
+ problem_path = Path(args.problem)
282
+ try:
283
+ return problem_path.read_text(encoding="utf-8"), problem_path.stem
284
+ except (OSError, UnicodeDecodeError) as exc:
285
+ parser.error(f"cannot read problem file {args.problem}: {exc}")
286
+ return task_text, "task"
287
+
288
+
289
+ def build_stats(result: Any, elapsed: float, model_id: str) -> dict:
290
+ """Summarise an AgentResult as the stats dict consumed by the harness.
291
+
292
+ Keeps the key shape of the old engine stats (``token_consumption``,
293
+ ``tool_usage``, ``execution_time_seconds``, ``step_limit_reached``) so
294
+ downstream consumers need no changes.
295
+ """
296
+ tool_usage: dict[str, int] = {}
297
+ for step in result.steps:
298
+ for call in step.get("tool_calls") or []:
299
+ name = call.get("name", "?")
300
+ tool_usage[name] = tool_usage.get(name, 0) + 1
301
+ return {
302
+ "token_consumption": {
303
+ "input_tokens": result.input_tokens,
304
+ "output_tokens": result.output_tokens,
305
+ "total_tokens": result.input_tokens + result.output_tokens,
306
+ "cost_usd": round(getattr(result, "cost", 0.0), 6),
307
+ },
308
+ "tool_usage": tool_usage,
309
+ "execution_time_seconds": elapsed,
310
+ "steps": len(result.steps),
311
+ "tool_calls_made": result.tool_calls_made,
312
+ "step_limit_reached": result.max_steps_reached,
313
+ "model": model_id,
314
+ }
315
+
316
+
317
+ def _progress(step_info: dict) -> None:
318
+ """Print a one-line progress note for a completed agent step."""
319
+ calls = step_info.get("tool_calls") or []
320
+ detail = ", ".join(c.get("name", "?") for c in calls) if calls else "final answer"
321
+ print(f"mcp-solver: step {step_info['step']}: {detail}", file=sys.stderr)
322
+
323
+
324
+ async def solve(
325
+ task: str,
326
+ api_key: str,
327
+ config: Any,
328
+ logger: Any,
329
+ quiet: bool,
330
+ ) -> Any:
331
+ """Run one solve: minion agent over the engine's ipython_mcp server."""
332
+ from mcp_minion import Agent, MCPManager
333
+
334
+ servers = {
335
+ "ipython": {
336
+ "command": sys.executable,
337
+ "args": ["-m", "agentic_python_coder.mcp_server"],
338
+ }
339
+ }
340
+ manager = MCPManager(servers)
341
+ await manager.__aenter__()
342
+ try:
343
+ if not manager.has_tool("submit_code"):
344
+ raise RuntimeError(
345
+ "the ipython_mcp server does not provide submit_code;"
346
+ " upgrade agentic-python-coder to >=3.4.0"
347
+ )
348
+ agent = Agent(
349
+ api_key=api_key,
350
+ tools=None,
351
+ config=config,
352
+ logger=logger,
353
+ mcp_manager=manager,
354
+ on_step=None if quiet else _progress,
355
+ )
356
+ return await agent.run_async(task)
357
+ finally:
358
+ await manager.__aexit__(None, None, None)
359
+
360
+
361
+ def print_stats(stats: dict) -> None:
362
+ """Print a compact one-line summary of the solve run to stderr."""
363
+ tokens = stats.get("token_consumption", {}).get("total_tokens", 0)
364
+ cost = stats.get("token_consumption", {}).get("cost_usd", 0)
365
+ exec_calls = stats.get("tool_usage", {}).get("python_exec", 0)
366
+ elapsed = stats.get("execution_time_seconds", 0)
367
+ cost_part = f", ${cost:.4f}" if cost else ""
368
+ print(
369
+ f"mcp-solver: {tokens:,} tokens{cost_part}, {exec_calls} exec calls,"
370
+ f" {elapsed:.1f}s",
371
+ file=sys.stderr,
372
+ )
373
+
374
+
375
+ def run_program(program: Path, with_packages: list[str]) -> int:
376
+ """Execute the saved program in a uv kernel; return its exit code.
377
+
378
+ The program's stdout (the solution JSON) is inherited so it becomes
379
+ this command's stdout.
380
+ """
381
+ cmd = ["uv", "run", "--no-project", "--python", PYTHON_VERSION]
382
+ for pkg in with_packages:
383
+ cmd += ["--with", pkg]
384
+ cmd += ["python", str(program)]
385
+ try:
386
+ return subprocess.run(cmd).returncode
387
+ except FileNotFoundError:
388
+ print(
389
+ "mcp-solver: 'uv' not found on PATH; it is required to run the"
390
+ " submitted program (https://docs.astral.sh/uv/).",
391
+ file=sys.stderr,
392
+ )
393
+ return 127
394
+
395
+
396
+ def main(argv: list[str] | None = None) -> int:
397
+ parser = build_parser()
398
+ args = parser.parse_args(argv)
399
+ task, task_basename = resolve_task(args, parser)
400
+ dev_path = resolve_dev_path(args, parser)
401
+
402
+ if dev_path is None and _is_unpublishable(mcp_solver.__version__):
403
+ print(
404
+ f"mcp-solver: installed version {mcp_solver.__version__} is not a"
405
+ " published release and dev mode is off.\n"
406
+ "Run from a source checkout, or pass --dev PATH (or --dev in a"
407
+ " checkout) to take helpers and templates from the local repo.",
408
+ file=sys.stderr,
409
+ )
410
+ return 2
411
+
412
+ with_packages = build_with_packages(args.solver, dev_path)
413
+ if dev_path is not None:
414
+ print(
415
+ f"mcp-solver: dev mode — helpers and templates from {dev_path}",
416
+ file=sys.stderr,
417
+ )
418
+
419
+ try:
420
+ import agentic_python_coder # noqa: F401 (kernel server + model aliases)
421
+ import mcp_minion # noqa: F401
422
+ except ImportError:
423
+ print(
424
+ "mcp-solver: the agent layer is not installed"
425
+ " (needs agentic-python-coder and mcp-minion).\n"
426
+ f"Install it with: uv pip install '{mcp_solver.DIST_NAME}[agent]'"
427
+ " (from a checkout: uv pip install -e '.[agent]')",
428
+ file=sys.stderr,
429
+ )
430
+ return 1
431
+
432
+ try:
433
+ model_id, api_params = resolve_model(args.model)
434
+ except ValueError as exc:
435
+ parser.error(str(exc))
436
+
437
+ api_key = find_api_key()
438
+ if api_key is None:
439
+ print(
440
+ "mcp-solver: no OpenRouter API key found.\n"
441
+ "Set OPENROUTER_API_KEY or put it in ~/.config/coder/.env"
442
+ " (see INSTALL.md).",
443
+ file=sys.stderr,
444
+ )
445
+ return 1
446
+
447
+ workdir = Path(args.workdir or os.getcwd()).resolve()
448
+ project_prompt = get_template(args.solver, root=dev_path)
449
+
450
+ from mcp_minion import AgentConfig, RunLogger, extract_last_submission
451
+
452
+ config = AgentConfig(
453
+ model=model_id,
454
+ max_steps=args.step_limit,
455
+ api_params=api_params,
456
+ system_prompt=project_prompt,
457
+ packages=with_packages,
458
+ )
459
+ logger = RunLogger(log_dir=workdir)
460
+
461
+ start = time.monotonic()
462
+ try:
463
+ result = asyncio.run(solve(task, api_key, config, logger, args.quiet))
464
+ except RuntimeError as exc:
465
+ print(f"mcp-solver: {exc}", file=sys.stderr)
466
+ return 1
467
+ elapsed = time.monotonic() - start
468
+
469
+ stats = build_stats(result, elapsed, model_id)
470
+ print_stats(stats)
471
+ if args.stats_json:
472
+ try:
473
+ Path(args.stats_json).write_text(json.dumps(stats, default=str))
474
+ except OSError as exc:
475
+ print(
476
+ f"mcp-solver: could not write stats to {args.stats_json}: {exc}",
477
+ file=sys.stderr,
478
+ )
479
+
480
+ code = extract_last_submission(result.steps)
481
+ if code is None:
482
+ print(
483
+ "mcp-solver: the run submitted no final program"
484
+ " (submit_code was never called successfully).",
485
+ file=sys.stderr,
486
+ )
487
+ return 3
488
+ program = workdir / f"{task_basename}_code.py"
489
+ program.write_text(code, encoding="utf-8")
490
+
491
+ print(f"mcp-solver: running {program}", file=sys.stderr)
492
+ return run_program(program, with_packages)
493
+
494
+
495
+ if __name__ == "__main__":
496
+ sys.exit(main())