marcus-toolkit 0.1.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.
- marcus_toolkit/__init__.py +27 -0
- marcus_toolkit/agent.py +451 -0
- marcus_toolkit/candidates.py +489 -0
- marcus_toolkit/client.py +319 -0
- marcus_toolkit/funnel.py +154 -0
- marcus_toolkit/gate.py +150 -0
- marcus_toolkit/integrity.py +251 -0
- marcus_toolkit/livecheck.py +223 -0
- marcus_toolkit/measure.py +197 -0
- marcus_toolkit/pricing.py +96 -0
- marcus_toolkit/record.py +84 -0
- marcus_toolkit/sampler.py +169 -0
- marcus_toolkit/seed.py +189 -0
- marcus_toolkit/server.py +348 -0
- marcus_toolkit/tender.py +369 -0
- marcus_toolkit/tending.py +946 -0
- marcus_toolkit/usage.py +222 -0
- marcus_toolkit/views.py +150 -0
- marcus_toolkit/watchdog.py +264 -0
- marcus_toolkit-0.1.0.dist-info/METADATA +98 -0
- marcus_toolkit-0.1.0.dist-info/RECORD +25 -0
- marcus_toolkit-0.1.0.dist-info/WHEEL +5 -0
- marcus_toolkit-0.1.0.dist-info/entry_points.txt +5 -0
- marcus_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- marcus_toolkit-0.1.0.dist-info/top_level.txt +1 -0
marcus_toolkit/server.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A Marcus server under test: private, model-free, and provably this run's.
|
|
3
|
+
|
|
4
|
+
What every extension's run script did by hand, once:
|
|
5
|
+
|
|
6
|
+
* a config with the SQLite provider, the model switched off and an empty key
|
|
7
|
+
(a placeholder key still fires one real call on startup: issue #905 and
|
|
8
|
+
``~/marcus-experiments/numeric-verifier/RESULTS.md``, "Incident"), the
|
|
9
|
+
context and events features on (opinions and decisions need them), memory
|
|
10
|
+
and visibility off, and its own HTTP port;
|
|
11
|
+
* an environment with every model-provider key stripped, its own
|
|
12
|
+
``MARCUS_DATA_DIR`` and ``MARCUS_CONFIG``, telemetry off, and
|
|
13
|
+
``PYTHONPATH`` at the pinned checkout;
|
|
14
|
+
* a refusal to start on a port someone else holds, a readiness probe, a
|
|
15
|
+
check that the port's owner is this run's server, and a pid file;
|
|
16
|
+
* a count of model-provider hosts in the server log afterwards, which must be zero.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import socket
|
|
24
|
+
import subprocess # nosec B404 - starting our own server, fixed argv
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
#: Keys stripped from the server's environment, so the server can make no paid call.
|
|
31
|
+
STRIPPED_KEYS = (
|
|
32
|
+
"ANTHROPIC_API_KEY",
|
|
33
|
+
"CLAUDE_API_KEY",
|
|
34
|
+
"OPENAI_API_KEY",
|
|
35
|
+
"GEMINI_API_KEY",
|
|
36
|
+
"PERPLEXITY_API_KEY",
|
|
37
|
+
)
|
|
38
|
+
#: Hosts whose appearance in a server log means a model-provider call was made.
|
|
39
|
+
MODEL_HOSTS = (
|
|
40
|
+
"api.anthropic.com",
|
|
41
|
+
"api.openai.com",
|
|
42
|
+
"generativelanguage.googleapis.com",
|
|
43
|
+
"api.perplexity.ai",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def free_port() -> int:
|
|
48
|
+
"""Return a TCP port nothing is listening on right now."""
|
|
49
|
+
with socket.socket() as sock:
|
|
50
|
+
sock.bind(("127.0.0.1", 0))
|
|
51
|
+
return int(sock.getsockname()[1])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|
55
|
+
"""Whether something accepts connections on ``host:port``."""
|
|
56
|
+
with socket.socket() as sock:
|
|
57
|
+
sock.settimeout(0.2)
|
|
58
|
+
return sock.connect_ex((host, port)) == 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def port_owner(port: int) -> int | None:
|
|
62
|
+
"""Return the pid listening on ``port``, by ``lsof``, or ``None``."""
|
|
63
|
+
try:
|
|
64
|
+
out = subprocess.run( # nosec B603 B607
|
|
65
|
+
["lsof", "-t", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
timeout=5,
|
|
69
|
+
).stdout.split()
|
|
70
|
+
except (OSError, subprocess.SubprocessError):
|
|
71
|
+
return None
|
|
72
|
+
return int(out[0]) if out else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def command_of(pid: int) -> str:
|
|
76
|
+
"""Return the command line of ``pid``, or an empty string."""
|
|
77
|
+
try:
|
|
78
|
+
return subprocess.run(
|
|
79
|
+
["ps", "-o", "command=", "-p", str(pid)],
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
timeout=5,
|
|
83
|
+
).stdout.strip() # nosec B603 B607
|
|
84
|
+
except (OSError, subprocess.SubprocessError):
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_config(
|
|
89
|
+
path: Path,
|
|
90
|
+
*,
|
|
91
|
+
port: int,
|
|
92
|
+
project_name: str,
|
|
93
|
+
sqlite_db_path: Path | None = None,
|
|
94
|
+
host: str = "127.0.0.1",
|
|
95
|
+
single_project_mode: bool = False,
|
|
96
|
+
) -> dict[str, object]:
|
|
97
|
+
"""
|
|
98
|
+
Write the server's config: SQLite, model off with an empty key, own port.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
path : Path
|
|
103
|
+
Where to write the JSON.
|
|
104
|
+
port : int
|
|
105
|
+
The HTTP port.
|
|
106
|
+
project_name : str
|
|
107
|
+
The default project name the server starts with.
|
|
108
|
+
sqlite_db_path : Optional[Path]
|
|
109
|
+
The board file; when omitted the data-directory resolver decides (issue #792).
|
|
110
|
+
host : str
|
|
111
|
+
The bind address.
|
|
112
|
+
single_project_mode : bool
|
|
113
|
+
``False`` lets one server hold several projects (ADR-0013 K6).
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
Dict[str, object]
|
|
118
|
+
The config written.
|
|
119
|
+
"""
|
|
120
|
+
# The server runs from the checkout, so every path it is given must be absolute.
|
|
121
|
+
kanban: dict[str, object] = {"provider": "sqlite"}
|
|
122
|
+
if sqlite_db_path is not None:
|
|
123
|
+
kanban["sqlite_db_path"] = str(Path(sqlite_db_path).resolve())
|
|
124
|
+
config: dict[str, object] = {
|
|
125
|
+
"kanban": kanban,
|
|
126
|
+
"default_project_name": f"{project_name} - Main Board",
|
|
127
|
+
"project_name": project_name,
|
|
128
|
+
"single_project_mode": single_project_mode,
|
|
129
|
+
"auto_find_board": False,
|
|
130
|
+
"ai": {"provider": "anthropic", "enabled": False, "anthropic_api_key": ""},
|
|
131
|
+
# The context feature is what opinions, decisions and task context ride
|
|
132
|
+
# on; without it every give_opinion is refused with "Context system not
|
|
133
|
+
# enabled" and every opinions read is empty (found by the wargame's
|
|
134
|
+
# deliberation rehearsal, 2026-11-11). Events feed the record. Memory
|
|
135
|
+
# and visibility stay off: nothing a run needs, and memory is a model path.
|
|
136
|
+
"features": {
|
|
137
|
+
"events": True,
|
|
138
|
+
"context": True,
|
|
139
|
+
"memory": False,
|
|
140
|
+
"visibility": False,
|
|
141
|
+
},
|
|
142
|
+
"transport": {
|
|
143
|
+
"type": "http",
|
|
144
|
+
"http_host": host,
|
|
145
|
+
"http_port": port,
|
|
146
|
+
"http_path": "/mcp",
|
|
147
|
+
"http_enabled": True,
|
|
148
|
+
"dual_mode": False,
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
path.write_text(json.dumps(config, indent=2) + "\n")
|
|
153
|
+
return config
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_env(
|
|
157
|
+
checkout: Path,
|
|
158
|
+
data_dir: Path,
|
|
159
|
+
config_path: Path,
|
|
160
|
+
base: dict[str, str] | None = None,
|
|
161
|
+
) -> dict[str, str]:
|
|
162
|
+
"""
|
|
163
|
+
Build the server's environment: keys stripped, paths set, telemetry off.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
checkout : Path
|
|
168
|
+
The pinned Marcus checkout, which becomes ``PYTHONPATH``.
|
|
169
|
+
data_dir : Path
|
|
170
|
+
``MARCUS_DATA_DIR``.
|
|
171
|
+
config_path : Path
|
|
172
|
+
``MARCUS_CONFIG``.
|
|
173
|
+
base : Optional[Dict[str, str]]
|
|
174
|
+
The environment to start from; the process environment by default.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
Dict[str, str]
|
|
179
|
+
"""
|
|
180
|
+
env = {
|
|
181
|
+
k: v
|
|
182
|
+
for k, v in (base if base is not None else os.environ).items()
|
|
183
|
+
if k not in STRIPPED_KEYS
|
|
184
|
+
}
|
|
185
|
+
env["MARCUS_DATA_DIR"] = str(Path(data_dir).resolve())
|
|
186
|
+
env["MARCUS_CONFIG"] = str(Path(config_path).resolve())
|
|
187
|
+
env["MARCUS_TELEMETRY"] = "off"
|
|
188
|
+
env["PYTHONPATH"] = str(Path(checkout).resolve())
|
|
189
|
+
return env
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def model_calls_in_log(log_path: Path) -> int:
|
|
193
|
+
"""Count lines of the server log that name a model-provider host."""
|
|
194
|
+
if not log_path.is_file():
|
|
195
|
+
return 0
|
|
196
|
+
text = log_path.read_text(errors="replace")
|
|
197
|
+
return sum(
|
|
198
|
+
line.count(h) > 0
|
|
199
|
+
for line in text.splitlines()
|
|
200
|
+
for h in MODEL_HOSTS
|
|
201
|
+
if h in line
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ServerUnderTest:
|
|
206
|
+
"""
|
|
207
|
+
Start, probe and stop one private Marcus server.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
checkout : Path
|
|
212
|
+
The pinned Marcus checkout to serve from (its ``src`` is importable).
|
|
213
|
+
data_dir : Path
|
|
214
|
+
The run's own ``MARCUS_DATA_DIR``.
|
|
215
|
+
config_path : Path
|
|
216
|
+
The config written by :func:`write_config`.
|
|
217
|
+
port : int
|
|
218
|
+
The HTTP port in that config.
|
|
219
|
+
log_path : Path
|
|
220
|
+
Where the server's stdout and stderr go.
|
|
221
|
+
python : str
|
|
222
|
+
The interpreter to run ``src.marcus_mcp.server`` with.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(
|
|
226
|
+
self,
|
|
227
|
+
checkout: Path,
|
|
228
|
+
data_dir: Path,
|
|
229
|
+
config_path: Path,
|
|
230
|
+
port: int,
|
|
231
|
+
log_path: Path,
|
|
232
|
+
python: str = "python",
|
|
233
|
+
) -> None:
|
|
234
|
+
self.checkout = checkout
|
|
235
|
+
self.data_dir = data_dir
|
|
236
|
+
self.config_path = config_path
|
|
237
|
+
self.port = port
|
|
238
|
+
self.log_path = log_path
|
|
239
|
+
self.python = python
|
|
240
|
+
self.process: subprocess.Popen[bytes] | None = None
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def url(self) -> str:
|
|
244
|
+
"""The MCP endpoint."""
|
|
245
|
+
return f"http://127.0.0.1:{self.port}/mcp/"
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def pid_file(self) -> Path:
|
|
249
|
+
"""Where the pid is written for the watchdog."""
|
|
250
|
+
return self.data_dir / "server.pid"
|
|
251
|
+
|
|
252
|
+
def start(self, wait_seconds: float = 60.0) -> int:
|
|
253
|
+
"""
|
|
254
|
+
Start the server and wait until it answers and the port is ours.
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
wait_seconds : float
|
|
259
|
+
How long to wait for readiness.
|
|
260
|
+
|
|
261
|
+
Returns
|
|
262
|
+
-------
|
|
263
|
+
int
|
|
264
|
+
The server's pid.
|
|
265
|
+
|
|
266
|
+
Raises
|
|
267
|
+
------
|
|
268
|
+
RuntimeError
|
|
269
|
+
If the port is held by someone else, the server never answers, or
|
|
270
|
+
the port's owner is not a Marcus server.
|
|
271
|
+
"""
|
|
272
|
+
if port_in_use(self.port):
|
|
273
|
+
raise RuntimeError(
|
|
274
|
+
f"port {self.port} is already in use; refusing to run against"
|
|
275
|
+
" another session's Marcus"
|
|
276
|
+
)
|
|
277
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
env = build_env(self.checkout, self.data_dir, self.config_path)
|
|
280
|
+
with self.log_path.open("ab") as log:
|
|
281
|
+
self.process = subprocess.Popen( # nosec B603
|
|
282
|
+
[self.python, "-m", "src.marcus_mcp.server", "--http", "--all-tools"],
|
|
283
|
+
cwd=str(self.checkout),
|
|
284
|
+
env=env,
|
|
285
|
+
stdout=log,
|
|
286
|
+
stderr=subprocess.STDOUT,
|
|
287
|
+
)
|
|
288
|
+
self.pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
self.pid_file.write_text(f"{self.process.pid}\n")
|
|
290
|
+
deadline = time.time() + wait_seconds
|
|
291
|
+
while time.time() < deadline:
|
|
292
|
+
if self.process.poll() is not None:
|
|
293
|
+
raise RuntimeError(
|
|
294
|
+
f"the server exited with {self.process.returncode};"
|
|
295
|
+
f" see {self.log_path}"
|
|
296
|
+
)
|
|
297
|
+
if self._answers():
|
|
298
|
+
break
|
|
299
|
+
time.sleep(0.5)
|
|
300
|
+
else:
|
|
301
|
+
self.stop()
|
|
302
|
+
raise RuntimeError(
|
|
303
|
+
f"the server never answered on {self.url} within {wait_seconds}s;"
|
|
304
|
+
f" see {self.log_path}"
|
|
305
|
+
)
|
|
306
|
+
owner = port_owner(self.port)
|
|
307
|
+
if owner is None or "src.marcus_mcp.server" not in command_of(owner):
|
|
308
|
+
self.stop()
|
|
309
|
+
raise RuntimeError(
|
|
310
|
+
f"port {self.port} is not owned by a Marcus server of this run"
|
|
311
|
+
f" (owner {owner})"
|
|
312
|
+
)
|
|
313
|
+
return self.process.pid
|
|
314
|
+
|
|
315
|
+
def _answers(self) -> bool:
|
|
316
|
+
try:
|
|
317
|
+
urllib.request.urlopen(
|
|
318
|
+
self.url, timeout=2
|
|
319
|
+
) # nosec B310 - our own local server
|
|
320
|
+
return True
|
|
321
|
+
except urllib.error.HTTPError:
|
|
322
|
+
return True
|
|
323
|
+
except (urllib.error.URLError, OSError):
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
def stop(self, wait_seconds: float = 20.0) -> None:
|
|
327
|
+
"""Stop the server and whoever else holds the port, then wait."""
|
|
328
|
+
pids: list[int] = []
|
|
329
|
+
if self.process is not None and self.process.poll() is None:
|
|
330
|
+
pids.append(self.process.pid)
|
|
331
|
+
owner = port_owner(self.port)
|
|
332
|
+
if owner is not None and owner not in pids:
|
|
333
|
+
pids.append(owner)
|
|
334
|
+
for pid in pids:
|
|
335
|
+
try:
|
|
336
|
+
os.kill(pid, 15)
|
|
337
|
+
except OSError:
|
|
338
|
+
pass
|
|
339
|
+
if self.process is not None:
|
|
340
|
+
try:
|
|
341
|
+
self.process.wait(timeout=wait_seconds)
|
|
342
|
+
except subprocess.TimeoutExpired:
|
|
343
|
+
self.process.kill()
|
|
344
|
+
self.process = None
|
|
345
|
+
|
|
346
|
+
def model_calls(self) -> int:
|
|
347
|
+
"""How many model-provider lines the log carries; a model-free run has zero."""
|
|
348
|
+
return model_calls_in_log(self.log_path)
|