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/client.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A minimal MCP-over-HTTP client for run-side tooling.
|
|
3
|
+
|
|
4
|
+
Lifted from ``dev-tools/experiments/runners/marcus_client.py`` in the Marcus
|
|
5
|
+
repository, which the Minecraft plumbing loaded by absolute file path from two
|
|
6
|
+
scripts. The transport (``urllib``) lives in :class:`MarcusMCPClient`; the SSE
|
|
7
|
+
envelope parsing is pure and unit-testable without a server.
|
|
8
|
+
|
|
9
|
+
Two additions. :meth:`MarcusMCPClient.call` raises instead of returning
|
|
10
|
+
``None``, for callers that must read back every reply body rather than infer
|
|
11
|
+
success from silence. And the handshake speaks the current protocol: it offers
|
|
12
|
+
version 2025-03-26 (what the Node client sends), keeps the version the server
|
|
13
|
+
answers with, sends it as ``MCP-Protocol-Version`` on every later request, and
|
|
14
|
+
records why a connect or call failed in ``last_error``. The first CI run of the
|
|
15
|
+
live check failed at connect against a server built on a newer ``mcp`` package
|
|
16
|
+
than the development machine's, and said nothing about why.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
_DEFAULT_MARCUS_URL = "http://127.0.0.1:4298/mcp/"
|
|
28
|
+
#: The protocol version offered on initialize; the server answers with its own.
|
|
29
|
+
PROTOCOL_VERSION = "2025-03-26"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MarcusClientError(RuntimeError):
|
|
33
|
+
"""A tool call that produced no readable reply."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_mcp_envelopes(raw: bytes) -> list[dict[str, Any]]:
|
|
37
|
+
"""
|
|
38
|
+
Read every JSON-RPC envelope out of an SSE or plain-JSON response body.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
raw : bytes
|
|
43
|
+
The raw HTTP response body.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
List[Dict[str, Any]]
|
|
48
|
+
The envelopes in order; empty when none parse.
|
|
49
|
+
"""
|
|
50
|
+
text = raw.decode(errors="replace")
|
|
51
|
+
envelopes: list[dict[str, Any]] = []
|
|
52
|
+
saw_data = False
|
|
53
|
+
for line in text.splitlines():
|
|
54
|
+
if not line.startswith("data:"):
|
|
55
|
+
continue
|
|
56
|
+
saw_data = True
|
|
57
|
+
try:
|
|
58
|
+
parsed = json.loads(line[len("data:") :].strip())
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
continue
|
|
61
|
+
if isinstance(parsed, dict):
|
|
62
|
+
envelopes.append(parsed)
|
|
63
|
+
if not saw_data and text.strip().startswith("{"):
|
|
64
|
+
try:
|
|
65
|
+
parsed = json.loads(text)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
return envelopes
|
|
68
|
+
if isinstance(parsed, dict):
|
|
69
|
+
envelopes.append(parsed)
|
|
70
|
+
return envelopes
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_mcp_tool_result(raw: bytes) -> dict[str, Any] | None:
|
|
74
|
+
"""
|
|
75
|
+
Parse an MCP ``tools/call`` HTTP response into its structured result.
|
|
76
|
+
|
|
77
|
+
The tool's own return value sits at ``result.structuredContent.result``.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
raw : bytes
|
|
82
|
+
The raw HTTP response body.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
Optional[Dict[str, Any]]
|
|
87
|
+
The tool's structured result, or ``None`` if none is present.
|
|
88
|
+
"""
|
|
89
|
+
for envelope in parse_mcp_envelopes(raw):
|
|
90
|
+
result = envelope.get("result")
|
|
91
|
+
if not isinstance(result, dict):
|
|
92
|
+
continue
|
|
93
|
+
structured = result.get("structuredContent")
|
|
94
|
+
if isinstance(structured, dict):
|
|
95
|
+
inner = structured.get("result")
|
|
96
|
+
if isinstance(inner, dict):
|
|
97
|
+
return inner
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MarcusMCPClient:
|
|
102
|
+
"""
|
|
103
|
+
Reusable MCP-over-HTTP client for one Marcus server.
|
|
104
|
+
|
|
105
|
+
Call :meth:`connect` once, then :meth:`call_tool` or :meth:`call` as often
|
|
106
|
+
as needed; the session id from the handshake is reused.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
marcus_url : str
|
|
111
|
+
The Marcus MCP HTTP endpoint. A trailing slash is forced: a POST to
|
|
112
|
+
``/mcp`` without one gets a 307 that ``urllib`` will not replay.
|
|
113
|
+
timeout : float
|
|
114
|
+
Per-request timeout in seconds.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self, marcus_url: str = _DEFAULT_MARCUS_URL, timeout: float = 10.0
|
|
119
|
+
) -> None:
|
|
120
|
+
self._url = marcus_url.rstrip("/") + "/"
|
|
121
|
+
self._timeout = timeout
|
|
122
|
+
self._session_id: str = ""
|
|
123
|
+
self._protocol_version: str = ""
|
|
124
|
+
self._request_id = 0
|
|
125
|
+
#: Why the last connect or call failed, for a record that must say so.
|
|
126
|
+
self.last_error: str = ""
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def url(self) -> str:
|
|
130
|
+
"""Return the endpoint this client posts to."""
|
|
131
|
+
return self._url
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def connected(self) -> bool:
|
|
135
|
+
"""Return True once :meth:`connect` has established a session."""
|
|
136
|
+
return bool(self._session_id)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def protocol_version(self) -> str:
|
|
140
|
+
"""Return the protocol version the server answered with, once connected."""
|
|
141
|
+
return self._protocol_version
|
|
142
|
+
|
|
143
|
+
def _headers(self) -> dict[str, str]:
|
|
144
|
+
headers = {
|
|
145
|
+
"Content-Type": "application/json",
|
|
146
|
+
"Accept": "application/json, text/event-stream",
|
|
147
|
+
}
|
|
148
|
+
if self._session_id:
|
|
149
|
+
headers["mcp-session-id"] = self._session_id
|
|
150
|
+
if self._protocol_version:
|
|
151
|
+
headers["MCP-Protocol-Version"] = self._protocol_version
|
|
152
|
+
return headers
|
|
153
|
+
|
|
154
|
+
def _send(self, payload: dict[str, Any]) -> tuple[str, bytes]:
|
|
155
|
+
"""
|
|
156
|
+
POST once, following a 307 or 308 with the same method and body.
|
|
157
|
+
|
|
158
|
+
Older servers redirect ``/mcp`` to ``/mcp/``; newer ones redirect the other
|
|
159
|
+
way. ``urllib`` refuses to replay a POST through a redirect, so it is done
|
|
160
|
+
here, and the endpoint the server named is kept for every later call.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
payload : dict[str, Any]
|
|
165
|
+
The JSON-RPC envelope.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
tuple[str, bytes]
|
|
170
|
+
The ``mcp-session-id`` header (``""`` when absent) and the body.
|
|
171
|
+
"""
|
|
172
|
+
for _ in range(4):
|
|
173
|
+
req = urllib.request.Request(
|
|
174
|
+
self._url,
|
|
175
|
+
data=json.dumps(payload).encode(),
|
|
176
|
+
headers=self._headers(),
|
|
177
|
+
method="POST",
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
with urllib.request.urlopen(
|
|
181
|
+
req, timeout=self._timeout
|
|
182
|
+
) as resp: # nosec B310
|
|
183
|
+
session = resp.headers.get("mcp-session-id", "") or ""
|
|
184
|
+
body: bytes = resp.read()
|
|
185
|
+
return session, body
|
|
186
|
+
except urllib.error.HTTPError as exc:
|
|
187
|
+
location = exc.headers.get("Location") if exc.headers else None
|
|
188
|
+
if exc.code in (307, 308) and location:
|
|
189
|
+
self._url = urllib.parse.urljoin(self._url, location)
|
|
190
|
+
continue
|
|
191
|
+
raise
|
|
192
|
+
raise urllib.error.URLError("too many redirects from the MCP endpoint")
|
|
193
|
+
|
|
194
|
+
def _post(self, payload: dict[str, Any]) -> bytes:
|
|
195
|
+
return self._send(payload)[1]
|
|
196
|
+
|
|
197
|
+
def connect(self) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Perform the MCP handshake and store the session id and protocol version.
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
bool
|
|
204
|
+
True if a session was established; on False, ``last_error`` says why.
|
|
205
|
+
"""
|
|
206
|
+
init_payload = {
|
|
207
|
+
"jsonrpc": "2.0",
|
|
208
|
+
"id": 1,
|
|
209
|
+
"method": "initialize",
|
|
210
|
+
"params": {
|
|
211
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
212
|
+
"capabilities": {},
|
|
213
|
+
"clientInfo": {"name": "marcus-toolkit", "version": "0.1"},
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
self._session_id = ""
|
|
217
|
+
self._protocol_version = ""
|
|
218
|
+
try:
|
|
219
|
+
session, body = self._send(init_payload)
|
|
220
|
+
for envelope in parse_mcp_envelopes(body):
|
|
221
|
+
if envelope.get("error"):
|
|
222
|
+
self.last_error = f"initialize refused: {envelope['error']}"
|
|
223
|
+
return False
|
|
224
|
+
result = envelope.get("result")
|
|
225
|
+
if isinstance(result, dict) and result.get("protocolVersion"):
|
|
226
|
+
self._protocol_version = str(result["protocolVersion"])
|
|
227
|
+
if not session:
|
|
228
|
+
self.last_error = "initialize reply carried no mcp-session-id header"
|
|
229
|
+
return False
|
|
230
|
+
self._session_id = session
|
|
231
|
+
if not self._protocol_version:
|
|
232
|
+
self._protocol_version = PROTOCOL_VERSION
|
|
233
|
+
self._post(
|
|
234
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
|
|
235
|
+
)
|
|
236
|
+
self.last_error = ""
|
|
237
|
+
return True
|
|
238
|
+
except urllib.error.HTTPError as exc:
|
|
239
|
+
detail = exc.read()[:300].decode(errors="replace")
|
|
240
|
+
self.last_error = f"HTTP {exc.code} from {self._url}: {detail}"
|
|
241
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc:
|
|
242
|
+
self.last_error = f"{type(exc).__name__}: {exc}"
|
|
243
|
+
self._session_id = ""
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def call_tool(
|
|
247
|
+
self, name: str, arguments: dict[str, Any] | None = None
|
|
248
|
+
) -> dict[str, Any] | None:
|
|
249
|
+
"""
|
|
250
|
+
Call a tool and return its structured result, or ``None`` on any failure.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
name : str
|
|
255
|
+
The tool name.
|
|
256
|
+
arguments : Optional[Dict[str, Any]]
|
|
257
|
+
The tool arguments.
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
Optional[Dict[str, Any]]
|
|
262
|
+
The reply body, or ``None`` when not connected, the request failed,
|
|
263
|
+
or the response carried no structured result; ``last_error`` says why.
|
|
264
|
+
"""
|
|
265
|
+
if not self._session_id:
|
|
266
|
+
self.last_error = f"{name}: not connected"
|
|
267
|
+
return None
|
|
268
|
+
self._request_id += 1
|
|
269
|
+
try:
|
|
270
|
+
raw = self._post(
|
|
271
|
+
{
|
|
272
|
+
"jsonrpc": "2.0",
|
|
273
|
+
"id": self._request_id,
|
|
274
|
+
"method": "tools/call",
|
|
275
|
+
"params": {"name": name, "arguments": arguments or {}},
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
except urllib.error.HTTPError as exc:
|
|
279
|
+
detail = exc.read()[:300].decode(errors="replace")
|
|
280
|
+
self.last_error = f"{name}: HTTP {exc.code}: {detail}"
|
|
281
|
+
return None
|
|
282
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
283
|
+
self.last_error = f"{name}: {type(exc).__name__}: {exc}"
|
|
284
|
+
return None
|
|
285
|
+
result = parse_mcp_tool_result(raw)
|
|
286
|
+
if result is None:
|
|
287
|
+
self.last_error = f"{name}: no structured result in the reply"
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
def call(
|
|
291
|
+
self, name: str, arguments: dict[str, Any] | None = None
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
"""
|
|
294
|
+
Call a tool and return its reply body, raising when there is none.
|
|
295
|
+
|
|
296
|
+
Parameters
|
|
297
|
+
----------
|
|
298
|
+
name : str
|
|
299
|
+
The tool name.
|
|
300
|
+
arguments : Optional[Dict[str, Any]]
|
|
301
|
+
The tool arguments.
|
|
302
|
+
|
|
303
|
+
Returns
|
|
304
|
+
-------
|
|
305
|
+
Dict[str, Any]
|
|
306
|
+
The reply body.
|
|
307
|
+
|
|
308
|
+
Raises
|
|
309
|
+
------
|
|
310
|
+
MarcusClientError
|
|
311
|
+
When the client is not connected or the call produced no readable
|
|
312
|
+
reply. The message names the tool and the reason.
|
|
313
|
+
"""
|
|
314
|
+
if not self._session_id:
|
|
315
|
+
raise MarcusClientError(f"{name}: not connected")
|
|
316
|
+
reply = self.call_tool(name, arguments)
|
|
317
|
+
if reply is None:
|
|
318
|
+
raise MarcusClientError(f"{name}: {self.last_error or 'no readable reply'}")
|
|
319
|
+
return reply
|
marcus_toolkit/funnel.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The candidate funnel, read from ``candidates.jsonl``, never reconstructed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .views import candidate_views
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def candidate_funnel(
|
|
15
|
+
path: Path | None,
|
|
16
|
+
calls: Iterable[dict[str, Any]] | None = None,
|
|
17
|
+
planned: Path | None = None,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""
|
|
20
|
+
Summarise the candidate funnel from the controller's ``candidates.jsonl``.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
path : Optional[Path]
|
|
25
|
+
``candidates.jsonl``; missing means candidates were off.
|
|
26
|
+
calls : Optional[Iterable[Dict[str, Any]]]
|
|
27
|
+
The agents' board calls, for the view log; with it, evaporations split
|
|
28
|
+
into ``evaporated_declined`` and ``evaporated_unseen``.
|
|
29
|
+
planned : Optional[Path]
|
|
30
|
+
A planner's ``planned.jsonl`` whose passes record ``candidates_posted``.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
Dict[str, Any]
|
|
35
|
+
Counts, per-candidate view numbers, the promotions, and the raw events.
|
|
36
|
+
"""
|
|
37
|
+
empty: dict[str, Any] = {
|
|
38
|
+
"promoted": 0,
|
|
39
|
+
"evaporated": 0,
|
|
40
|
+
"open_at_end": 0,
|
|
41
|
+
"shown_to": {},
|
|
42
|
+
"declined_by": {},
|
|
43
|
+
"awaiting_turn": {},
|
|
44
|
+
"abstained_by": {},
|
|
45
|
+
"abstentions": 0,
|
|
46
|
+
"promotions": [],
|
|
47
|
+
"evaporated_declined": 0,
|
|
48
|
+
"evaporated_unseen": 0,
|
|
49
|
+
"posted_by_planner": 0,
|
|
50
|
+
"posted_from_proposals": 0,
|
|
51
|
+
"posted_from_observations": 0,
|
|
52
|
+
"posted_by_coordinator": 0,
|
|
53
|
+
"objections": 0,
|
|
54
|
+
"mean_seconds_to_promotion": None,
|
|
55
|
+
"supporters": {},
|
|
56
|
+
"views": {},
|
|
57
|
+
"events": [],
|
|
58
|
+
}
|
|
59
|
+
by_planner: set[str] = set()
|
|
60
|
+
if planned is not None and planned.exists():
|
|
61
|
+
for line in planned.read_text(errors="replace").splitlines():
|
|
62
|
+
try:
|
|
63
|
+
record = json.loads(line)
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
continue
|
|
66
|
+
for subject in record.get("candidates_posted") or []:
|
|
67
|
+
by_planner.add(str(subject))
|
|
68
|
+
if path is None or not path.exists():
|
|
69
|
+
return {**empty, "posted_by_planner": len(by_planner)}
|
|
70
|
+
events: list[dict[str, Any]] = []
|
|
71
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
72
|
+
try:
|
|
73
|
+
events.append(json.loads(line))
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
continue
|
|
76
|
+
promoted = [e for e in events if e.get("event") == "promoted"]
|
|
77
|
+
evaporated = [e for e in events if e.get("event") == "evaporated"]
|
|
78
|
+
open_at_end = [e for e in events if e.get("event") == "open_at_end"]
|
|
79
|
+
posted_all = [e for e in events if e.get("event") == "posted"]
|
|
80
|
+
observed = [e for e in posted_all if e.get("observers")]
|
|
81
|
+
coordinator = [e for e in posted_all if e.get("from") == "coordinator"]
|
|
82
|
+
proposals = [
|
|
83
|
+
e
|
|
84
|
+
for e in posted_all
|
|
85
|
+
if not e.get("observers") and e.get("from") != "coordinator"
|
|
86
|
+
]
|
|
87
|
+
supporters: Counter[str] = Counter()
|
|
88
|
+
for e in promoted:
|
|
89
|
+
for agent in e.get("supporters") or []:
|
|
90
|
+
supporters[agent] += 1
|
|
91
|
+
times = [
|
|
92
|
+
int(e["seconds_to_promotion"])
|
|
93
|
+
for e in promoted
|
|
94
|
+
if e.get("seconds_to_promotion") is not None
|
|
95
|
+
]
|
|
96
|
+
closed_at = {
|
|
97
|
+
str(e.get("subject")): str(e.get("at"))
|
|
98
|
+
for e in events
|
|
99
|
+
if e.get("event") in ("promoted", "evaporated")
|
|
100
|
+
and e.get("subject")
|
|
101
|
+
and e.get("at")
|
|
102
|
+
}
|
|
103
|
+
seen_by = (
|
|
104
|
+
candidate_views(list(calls), before=closed_at) if calls is not None else {}
|
|
105
|
+
)
|
|
106
|
+
subjects = {str(e.get("subject")) for e in events if e.get("subject")} | by_planner
|
|
107
|
+
views = {s: len(seen_by.get(s, [])) for s in sorted(subjects)}
|
|
108
|
+
declined = sum(1 for e in evaporated if views.get(str(e.get("subject")), 0) > 0)
|
|
109
|
+
closed = promoted + evaporated + open_at_end
|
|
110
|
+
|
|
111
|
+
def per(key: str) -> dict[str, int]:
|
|
112
|
+
return {
|
|
113
|
+
str(e.get("subject")): len(e.get(key) or [])
|
|
114
|
+
for e in closed
|
|
115
|
+
if e.get("subject")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"promoted": len(promoted),
|
|
120
|
+
"evaporated": len(evaporated),
|
|
121
|
+
"open_at_end": len(open_at_end),
|
|
122
|
+
"shown_to": per("shown_to"),
|
|
123
|
+
"declined_by": per("declined_by"),
|
|
124
|
+
"awaiting_turn": per("awaiting_turn"),
|
|
125
|
+
"abstained_by": per("abstained_by"),
|
|
126
|
+
"abstentions": sum(
|
|
127
|
+
len(e.get("agents") or []) for e in events if e.get("event") == "abstained"
|
|
128
|
+
),
|
|
129
|
+
"promotions": [
|
|
130
|
+
{
|
|
131
|
+
"name": e.get("name"),
|
|
132
|
+
"subject": e.get("subject"),
|
|
133
|
+
"supporters": list(e.get("supporters") or []),
|
|
134
|
+
"objectors": list(e.get("objectors") or []),
|
|
135
|
+
"seconds_to_promotion": e.get("seconds_to_promotion"),
|
|
136
|
+
"shown_to": len(e.get("shown_to") or []),
|
|
137
|
+
"declined_by": len(e.get("declined_by") or []),
|
|
138
|
+
"abstained_by": len(e.get("abstained_by") or []),
|
|
139
|
+
"task_ids": list(e.get("task_ids") or []),
|
|
140
|
+
}
|
|
141
|
+
for e in promoted
|
|
142
|
+
],
|
|
143
|
+
"evaporated_declined": declined,
|
|
144
|
+
"evaporated_unseen": len(evaporated) - declined,
|
|
145
|
+
"views": views,
|
|
146
|
+
"posted_by_planner": len(by_planner),
|
|
147
|
+
"posted_from_proposals": len(proposals),
|
|
148
|
+
"posted_from_observations": len(observed),
|
|
149
|
+
"posted_by_coordinator": len(coordinator),
|
|
150
|
+
"objections": sum(1 for e in promoted + evaporated if e.get("objectors")),
|
|
151
|
+
"mean_seconds_to_promotion": round(sum(times) / len(times)) if times else None,
|
|
152
|
+
"supporters": dict(sorted(supporters.items())),
|
|
153
|
+
"events": events,
|
|
154
|
+
}
|
marcus_toolkit/gate.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Does the agreement chain still work? Asked of a run's record, in three links.
|
|
3
|
+
|
|
4
|
+
Work is agreed before it is created: the coordinator posts a candidate; agents
|
|
5
|
+
answer it; two supporters promote it into a task an agent then picks up. Three
|
|
6
|
+
links, each needing the one before it: a candidate is posted at all; support
|
|
7
|
+
is recorded against a candidate; a promotion creates a task on the board. The
|
|
8
|
+
verdict is one of four, and PARTIAL is a real outcome rather than a softened
|
|
9
|
+
failure: PROVEN, PARTIAL (posted and supported, nobody promoted, an agent
|
|
10
|
+
choice), FAILED (a link that does not depend on an agent's choice did not fire,
|
|
11
|
+
or a promotion created nothing), NOT PROVEN (nothing ran).
|
|
12
|
+
|
|
13
|
+
Lifted from the Minecraft plumbing's ``live_check_no_regression.py``, the
|
|
14
|
+
reading half; starting a run is the extension's business.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sqlite3
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .measure import read_jsonl
|
|
24
|
+
|
|
25
|
+
PROVEN, FAILED, NOT_PROVEN, PARTIAL = 0, 1, 2, 3
|
|
26
|
+
NAMES = {
|
|
27
|
+
PROVEN: "PROVEN",
|
|
28
|
+
FAILED: "FAILED",
|
|
29
|
+
NOT_PROVEN: "NOT PROVEN",
|
|
30
|
+
PARTIAL: "PARTIAL",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def task_exists(kanban_db: Path, task_id: str) -> bool | None:
|
|
35
|
+
"""Return whether a task with this id is on the board, or ``None`` if unreadable."""
|
|
36
|
+
if not kanban_db.exists():
|
|
37
|
+
return None
|
|
38
|
+
con = sqlite3.connect(f"file:{kanban_db}?mode=ro", uri=True)
|
|
39
|
+
try:
|
|
40
|
+
row = con.execute(
|
|
41
|
+
"select count(*) from tasks where id = ?", (task_id,)
|
|
42
|
+
).fetchone()
|
|
43
|
+
return bool(row and row[0])
|
|
44
|
+
except sqlite3.Error:
|
|
45
|
+
return None
|
|
46
|
+
finally:
|
|
47
|
+
con.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_chain(
|
|
51
|
+
candidates_jsonl: Path, kanban_db: Path, planned_jsonl: Path | None = None
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
Report which links of the chain fired.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
candidates_jsonl : Path
|
|
59
|
+
The controller's event stream.
|
|
60
|
+
kanban_db : Path
|
|
61
|
+
The run's own board, to confirm each promoted task id.
|
|
62
|
+
planned_jsonl : Optional[Path]
|
|
63
|
+
A planner's passes, whose ``candidates_posted`` count as postings.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
Dict[str, Any]
|
|
68
|
+
``code`` (0 proven, 3 partial, 1 failed, 2 not proven), ``verdict``,
|
|
69
|
+
the three link counts, and ``lines`` explaining the reading.
|
|
70
|
+
"""
|
|
71
|
+
events = read_jsonl(candidates_jsonl)
|
|
72
|
+
passes = read_jsonl(planned_jsonl) if planned_jsonl else []
|
|
73
|
+
by_planner: set[str] = set()
|
|
74
|
+
for record in passes:
|
|
75
|
+
for subject in record.get("candidates_posted") or []:
|
|
76
|
+
by_planner.add(str(subject))
|
|
77
|
+
# A candidate that closed, failed or stayed open was necessarily posted, so
|
|
78
|
+
# link 1 counts every subject any candidate event names, not only the
|
|
79
|
+
# "posted" events a planning pass may record elsewhere.
|
|
80
|
+
candidate_events = {
|
|
81
|
+
"posted",
|
|
82
|
+
"promoted",
|
|
83
|
+
"evaporated",
|
|
84
|
+
"open_at_end",
|
|
85
|
+
"promotion_failed",
|
|
86
|
+
"promotion_abandoned",
|
|
87
|
+
"abstained",
|
|
88
|
+
"already_on_board",
|
|
89
|
+
}
|
|
90
|
+
posted = {
|
|
91
|
+
str(e.get("subject"))
|
|
92
|
+
for e in events
|
|
93
|
+
if e.get("event") in candidate_events and e.get("subject")
|
|
94
|
+
} | by_planner
|
|
95
|
+
promoted = [e for e in events if e.get("event") == "promoted"]
|
|
96
|
+
failed = [e for e in events if e.get("event") == "promotion_failed"]
|
|
97
|
+
supported = [e for e in events if (e.get("supporters") or [])]
|
|
98
|
+
lines: list[str] = [
|
|
99
|
+
f"LINK 1, candidates posted: {len(posted)}",
|
|
100
|
+
f"LINK 2, candidates with support recorded: {len(supported)}",
|
|
101
|
+
f"LINK 3, promotions: {len(promoted)} ({len(failed)} refused by the board)",
|
|
102
|
+
]
|
|
103
|
+
out: dict[str, Any] = {
|
|
104
|
+
"posted": len(posted),
|
|
105
|
+
"supported": len(supported),
|
|
106
|
+
"promoted": len(promoted),
|
|
107
|
+
"refused": len(failed),
|
|
108
|
+
"lines": lines,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def finish(code: int, reason: str) -> dict[str, Any]:
|
|
112
|
+
lines.append(f"VERDICT: {NAMES[code]} -- {reason}")
|
|
113
|
+
return {**out, "code": code, "verdict": NAMES[code]}
|
|
114
|
+
|
|
115
|
+
if not events and not passes:
|
|
116
|
+
return finish(NOT_PROVEN, "nothing was tended, so no link was exercised.")
|
|
117
|
+
if not posted:
|
|
118
|
+
return finish(
|
|
119
|
+
FAILED,
|
|
120
|
+
"no candidate was posted; posting does not depend on an agent's choice.",
|
|
121
|
+
)
|
|
122
|
+
for event in promoted:
|
|
123
|
+
ids = [str(i) for i in (event.get("task_ids") or [])]
|
|
124
|
+
if not ids:
|
|
125
|
+
return finish(FAILED, "a promotion created nothing.")
|
|
126
|
+
for task_id in ids:
|
|
127
|
+
if task_exists(kanban_db, task_id) is not True:
|
|
128
|
+
return finish(
|
|
129
|
+
FAILED,
|
|
130
|
+
f"promoted task {task_id[:12]} is not on the run's own board.",
|
|
131
|
+
)
|
|
132
|
+
lines.append(f" promoted {str(event.get('name'))[:60]}: task_ids {ids}")
|
|
133
|
+
if failed and not promoted:
|
|
134
|
+
return finish(FAILED, "every promotion was refused by the board.")
|
|
135
|
+
if not promoted:
|
|
136
|
+
if not supported:
|
|
137
|
+
return finish(
|
|
138
|
+
PARTIAL,
|
|
139
|
+
"candidates posted and no agent answered any; link 2 unexercised.",
|
|
140
|
+
)
|
|
141
|
+
return finish(
|
|
142
|
+
PARTIAL,
|
|
143
|
+
"support was recorded and no candidate reached promotion;"
|
|
144
|
+
" link 3 unexercised.",
|
|
145
|
+
)
|
|
146
|
+
return finish(
|
|
147
|
+
PROVEN,
|
|
148
|
+
"candidates posted, support recorded, and a promotion created a task"
|
|
149
|
+
" on the board.",
|
|
150
|
+
)
|