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.
@@ -0,0 +1,27 @@
1
+ """
2
+ The run-side toolkit Marcus extensions share (issue #914).
3
+
4
+ An extension is a program that seeds a Marcus board and runs agents against
5
+ it for one purpose. Every extension so far rebuilt the same scaffolding:
6
+ a private server for the run, seeding, guards, a cost watchdog, a board
7
+ sampler, measures and a run record. This package is that scaffolding,
8
+ lifted from the Minecraft plumbing (``~/marcus-experiments/minecraft-plumbing``)
9
+ with the defects its runs found fixed once.
10
+
11
+ Nothing here imports Marcus's own code under ``src/``: the toolkit talks to
12
+ a server over MCP and reads its files, the same way an extension must
13
+ (``docs/architecture/extension-contract.md``).
14
+ """
15
+
16
+ __all__ = [
17
+ "client",
18
+ "pricing",
19
+ "usage",
20
+ "watchdog",
21
+ "sampler",
22
+ "integrity",
23
+ "server",
24
+ "seed",
25
+ "record",
26
+ "measure",
27
+ ]
@@ -0,0 +1,451 @@
1
+ """
2
+ A pull-loop agent shell in Python: the Node client's ``board.js``, for Python runtimes.
3
+
4
+ Register with a project (Marcus hands work only to a registered agent), pull,
5
+ absorb "ask again" replies without a model turn, hold the task and its lease
6
+ epoch across a restart, report with the lease epoch and evidence, leave
7
+ markers, record decisions, read the board. Every call is one record with the
8
+ six fields the Minecraft runs measure: ``at``, ``agent``, ``tool``, ``args``,
9
+ ``outcome``, ``ms``, ``reply``.
10
+
11
+ Nothing here talks to another agent. The board is the only channel.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from collections.abc import Callable, Sequence
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from typing import Any, Protocol
22
+
23
+
24
+ class ToolClient(Protocol):
25
+ """What the shell needs from a client: a tool call returning a reply or None."""
26
+
27
+ def call_tool(
28
+ self, name: str, arguments: dict[str, Any] | None = None
29
+ ) -> dict[str, Any] | None:
30
+ """Call a tool and return its reply body, or ``None``."""
31
+
32
+
33
+ def outcome_of(tool: str, reply: dict[str, Any]) -> str:
34
+ """
35
+ Classify a reply the way the Minecraft harness does.
36
+
37
+ Parameters
38
+ ----------
39
+ tool : str
40
+ The tool called.
41
+ reply : Dict[str, Any]
42
+ The reply body.
43
+
44
+ Returns
45
+ -------
46
+ str
47
+ ``transport_error``, ``drained``, ``assigned``, ``retry``, ``failed`` or ``ok``.
48
+ """
49
+ if reply.get("transport_error") or reply.get("rpc_error"):
50
+ return "transport_error"
51
+ if reply.get("status") == "BOARD_DRAINED":
52
+ return "drained"
53
+ if tool == "request_next_task":
54
+ return "assigned" if reply.get("success") is True else "retry"
55
+ return "failed" if reply.get("success") is False else "ok"
56
+
57
+
58
+ def jsonl_logger(path: Path) -> Callable[[dict[str, Any]], None]:
59
+ """
60
+ Append one JSON line per record to ``path``, flushed at once.
61
+
62
+ Parameters
63
+ ----------
64
+ path : Path
65
+ ``<state>/<agent>/board-calls.jsonl``; parents are created.
66
+
67
+ Returns
68
+ -------
69
+ Callable[[Dict[str, Any]], None]
70
+ """
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+
73
+ def log(record: dict[str, Any]) -> None:
74
+ with path.open("a") as handle:
75
+ handle.write(json.dumps(record, ensure_ascii=False) + "\n")
76
+
77
+ return log
78
+
79
+
80
+ class BoardAgent:
81
+ """
82
+ One agent's board client.
83
+
84
+ Parameters
85
+ ----------
86
+ client : ToolClient
87
+ A connected MCP client.
88
+ agent_id : str
89
+ The agent's id on the board.
90
+ project_id : str
91
+ The project it registers to.
92
+ role : str
93
+ The role string sent on registration (not enforced by the kernel).
94
+ skills : Sequence[str]
95
+ The skills sent on registration.
96
+ project_root : str
97
+ Where artifacts are written; a directory that is not a repository.
98
+ log : Optional[Callable[[Dict[str, Any]], None]]
99
+ Receives one record per board call.
100
+ hold_path : Optional[Path]
101
+ Where the held task and its lease epoch persist across a restart.
102
+ sleep : Callable[[float], None]
103
+ The sleep used to absorb retries; tests pass a stub.
104
+ now : Callable[[], float]
105
+ The clock, in seconds.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ client: ToolClient,
111
+ agent_id: str,
112
+ project_id: str,
113
+ *,
114
+ role: str = "worker",
115
+ skills: Sequence[str] = ("worker",),
116
+ project_root: str = "",
117
+ log: Callable[[dict[str, Any]], None] | None = None,
118
+ hold_path: Path | None = None,
119
+ sleep: Callable[[float], None] = time.sleep,
120
+ now: Callable[[], float] = time.time,
121
+ ) -> None:
122
+ self.client = client
123
+ self.agent_id = agent_id
124
+ self.project_id = project_id
125
+ self.role = role
126
+ self.skills = list(skills)
127
+ self.project_root = project_root
128
+ self.log = log
129
+ self.hold_path = hold_path
130
+ self.sleep = sleep
131
+ self.now = now
132
+ self.registered = False
133
+ self.objective: str | None = None
134
+ self.task: dict[str, Any] | None = None
135
+ self.lease_epoch: int | None = None
136
+ if hold_path is not None and hold_path.is_file():
137
+ held = json.loads(hold_path.read_text())
138
+ self.task = held.get("task")
139
+ self.lease_epoch = held.get("lease_epoch")
140
+
141
+ def _remember(self) -> None:
142
+ if self.hold_path is None:
143
+ return
144
+ if self.task:
145
+ self.hold_path.parent.mkdir(parents=True, exist_ok=True)
146
+ self.hold_path.write_text(
147
+ json.dumps({"task": self.task, "lease_epoch": self.lease_epoch})
148
+ )
149
+ elif self.hold_path.exists():
150
+ self.hold_path.unlink()
151
+
152
+ def board(self, tool: str, args: dict[str, Any]) -> dict[str, Any]:
153
+ """
154
+ Call one tool, timing and logging it.
155
+
156
+ Parameters
157
+ ----------
158
+ tool : str
159
+ The tool name.
160
+ args : Dict[str, Any]
161
+ The arguments.
162
+
163
+ Returns
164
+ -------
165
+ Dict[str, Any]
166
+ The reply body; a missing reply becomes a transport error.
167
+ """
168
+ started = self.now()
169
+ reply = self.client.call_tool(tool, args)
170
+ if reply is None:
171
+ reply = {"success": False, "error": "no reply", "transport_error": True}
172
+ if self.log:
173
+ self.log(
174
+ {
175
+ "at": datetime.now(UTC).isoformat(),
176
+ "agent": self.agent_id,
177
+ "tool": tool,
178
+ "args": args,
179
+ "outcome": outcome_of(tool, reply),
180
+ "ms": int((self.now() - started) * 1000),
181
+ "reply": reply,
182
+ }
183
+ )
184
+ return reply
185
+
186
+ def register(self) -> dict[str, Any]:
187
+ """Register with the project and read its objective once."""
188
+ reply = self.board(
189
+ "register_agent",
190
+ {
191
+ "agent_id": self.agent_id,
192
+ "name": self.agent_id,
193
+ "role": self.role,
194
+ "skills": self.skills,
195
+ "project_id": self.project_id,
196
+ },
197
+ )
198
+ if reply.get("success") is False:
199
+ return reply
200
+ self.registered = True
201
+ primer = self.board(
202
+ "query_project_history",
203
+ {
204
+ "project_id": self.project_id,
205
+ "query_type": "opinions",
206
+ "kind": "objective",
207
+ "limit": 1,
208
+ },
209
+ )
210
+ data = primer.get("data") if primer.get("success") is not False else None
211
+ try:
212
+ what = data[0]["opinions"][0]["what"] if data else None
213
+ except (KeyError, IndexError, TypeError):
214
+ what = None
215
+ if isinstance(what, str) and what.strip():
216
+ self.objective = what.strip()
217
+ return reply
218
+
219
+ def pull(
220
+ self,
221
+ absorb: bool = False,
222
+ max_asks: int | None = None,
223
+ wait_seconds: float | None = None,
224
+ ) -> dict[str, Any]:
225
+ """
226
+ Ask for work.
227
+
228
+ Parameters
229
+ ----------
230
+ absorb : bool
231
+ Wait out "ask again" replies here, one board call per ask.
232
+ max_asks : Optional[int]
233
+ Stop absorbing after this many asks.
234
+ wait_seconds : Optional[float]
235
+ Override the reply's ``retry_after_seconds``.
236
+
237
+ Returns
238
+ -------
239
+ Dict[str, Any]
240
+ ``status`` is ``assigned`` (with ``task``, ``lease_epoch``, ``context``,
241
+ ``opinions``, maybe ``objective``), ``retry``, ``drained``, ``error`` or
242
+ ``holding``.
243
+ """
244
+ if not self.registered:
245
+ refused = self.register()
246
+ if refused.get("success") is False:
247
+ return {"status": "error", "reply": refused}
248
+ if self.task:
249
+ return {"status": "holding", "task": self.task}
250
+ asks = 0
251
+ while True:
252
+ reply = self.board("request_next_task", {"agent_id": self.agent_id})
253
+ asks += 1
254
+ task = reply.get("task")
255
+ if reply.get("success") is True and isinstance(task, dict):
256
+ self.task = {"id": task.get("id"), "name": task.get("name")}
257
+ self.lease_epoch = reply.get("lease_epoch")
258
+ self._remember()
259
+ context = self.board("get_task_context", {"task_id": task.get("id")})
260
+ opinions = self.board(
261
+ "query_project_history",
262
+ {
263
+ "project_id": self.project_id,
264
+ "query_type": "opinions",
265
+ "limit": 20,
266
+ },
267
+ )
268
+ out: dict[str, Any] = {
269
+ "status": "assigned",
270
+ "task": task,
271
+ "lease_epoch": self.lease_epoch,
272
+ "context": context,
273
+ "opinions": opinions,
274
+ }
275
+ if self.objective:
276
+ out["objective"] = self.objective
277
+ return out
278
+ if reply.get("status") == "BOARD_DRAINED":
279
+ return {"status": "drained", "reply": reply}
280
+ if reply.get("transport_error") or reply.get("rpc_error"):
281
+ return {"status": "error", "reply": reply}
282
+ if not absorb or (max_asks is not None and asks >= max_asks):
283
+ return {"status": "retry", "reply": reply}
284
+ wait = (
285
+ wait_seconds
286
+ if wait_seconds is not None
287
+ else max(1.0, float(reply.get("retry_after_seconds") or 30))
288
+ )
289
+ self.sleep(wait)
290
+
291
+ def report(
292
+ self,
293
+ status: str,
294
+ message: str,
295
+ *,
296
+ progress: int | None = None,
297
+ subject: str | None = None,
298
+ observation: str | None = None,
299
+ evidence: dict[str, Any] | None = None,
300
+ ) -> dict[str, Any]:
301
+ """
302
+ Report on the held task; completion or blocking clears the hold.
303
+
304
+ Parameters
305
+ ----------
306
+ status : str
307
+ ``in_progress``, ``completed`` or ``blocked``.
308
+ message : str
309
+ What was done.
310
+ progress : Optional[int]
311
+ Percent; 100 for completed, else 50 by default.
312
+ subject : Optional[str]
313
+ A marker subject, so the report is also an opinion.
314
+ observation : Optional[str]
315
+ The marker's text.
316
+ evidence : Optional[Dict[str, Any]]
317
+ The structured evidence the close carries.
318
+
319
+ Returns
320
+ -------
321
+ Dict[str, Any]
322
+ """
323
+ if not self.task:
324
+ return {"success": False, "error": "You hold no task."}
325
+ args: dict[str, Any] = {
326
+ "agent_id": self.agent_id,
327
+ "task_id": self.task["id"],
328
+ "status": status,
329
+ "progress": (
330
+ progress
331
+ if progress is not None
332
+ else (100 if status == "completed" else 50)
333
+ ),
334
+ "message": message,
335
+ }
336
+ if subject and subject.strip():
337
+ args["subject"] = subject.strip()
338
+ if observation and observation.strip():
339
+ args["observation"] = observation.strip()
340
+ if evidence:
341
+ args["evidence"] = evidence
342
+ if self.lease_epoch:
343
+ args["lease_epoch"] = self.lease_epoch
344
+ reply = self.board("report_task_progress", args)
345
+ if reply.get("success") is not False and status != "in_progress":
346
+ self.task = None
347
+ self.lease_epoch = None
348
+ self._remember()
349
+ return reply
350
+
351
+ def _registered(self) -> dict[str, Any] | None:
352
+ """Register first if needed; return the refusal when registration fails."""
353
+ if self.registered:
354
+ return None
355
+ reply = self.register()
356
+ return reply if reply.get("success") is False else None
357
+
358
+ def opinion(
359
+ self,
360
+ subject: str,
361
+ what: str,
362
+ why: str = "",
363
+ impact: str = "",
364
+ about_task: bool = False,
365
+ ) -> dict[str, Any]:
366
+ """Leave a marker on the board; registers first when needed (K6)."""
367
+ refused = self._registered()
368
+ if refused is not None:
369
+ return refused
370
+ args: dict[str, Any] = {
371
+ "agent_id": self.agent_id,
372
+ "subject": subject,
373
+ "what": what,
374
+ "why": why,
375
+ "impact": impact,
376
+ }
377
+ if about_task and self.task:
378
+ args["task_id"] = self.task["id"]
379
+ return self.board("give_opinion", args)
380
+
381
+ def decide(
382
+ self, what: str, why: str, subject: str | None = None, kind: str = "decision"
383
+ ) -> dict[str, Any]:
384
+ """Record what the agent did and why; never counted toward a candidate."""
385
+ refused = self._registered()
386
+ if refused is not None:
387
+ return refused
388
+ mark = "DECISION" if kind == "decision" else "THOUGHT"
389
+ args: dict[str, Any] = {
390
+ "agent_id": self.agent_id,
391
+ "decision": (
392
+ f"{mark}: {what.strip().rstrip('.')}"
393
+ f" because {why.strip().rstrip('.')}."
394
+ ),
395
+ "task_id": self.task["id"] if self.task else "",
396
+ }
397
+ if subject and subject.strip():
398
+ args["subject"] = subject.strip()
399
+ return self.board("log_decision", args)
400
+
401
+ def artifact(
402
+ self,
403
+ filename: str,
404
+ content: str,
405
+ description: str = "",
406
+ artifact_type: str = "world-state",
407
+ ) -> dict[str, Any]:
408
+ """Put a file on the board as evidence for the held task."""
409
+ if not self.task:
410
+ return {"success": False, "error": "You hold no task."}
411
+ return self.board(
412
+ "log_artifact",
413
+ {
414
+ "task_id": self.task["id"],
415
+ "filename": filename,
416
+ "content": content,
417
+ "artifact_type": artifact_type,
418
+ "project_root": self.project_root,
419
+ "description": description,
420
+ },
421
+ )
422
+
423
+ def opinions(self, limit: int = 50, kind: str | None = None) -> dict[str, Any]:
424
+ """Read the board's markers."""
425
+ args: dict[str, Any] = {
426
+ "project_id": self.project_id,
427
+ "query_type": "opinions",
428
+ "limit": limit,
429
+ }
430
+ if kind:
431
+ args["kind"] = kind
432
+ return self.board("query_project_history", args)
433
+
434
+ def decisions(self, limit: int = 20) -> dict[str, Any]:
435
+ """Read the board's decisions."""
436
+ return self.board(
437
+ "query_project_history",
438
+ {
439
+ "project_id": self.project_id,
440
+ "query_type": "decisions",
441
+ "kind": "decision",
442
+ "limit": limit,
443
+ },
444
+ )
445
+
446
+ def status(self) -> dict[str, Any]:
447
+ """Read the project's status."""
448
+ return self.board("get_project_status", {})
449
+
450
+
451
+ __all__: list[str] = ["BoardAgent", "ToolClient", "jsonl_logger", "outcome_of"]