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,369 @@
1
+ """
2
+ The tender: a controller process that applies the candidate rules to a board.
3
+
4
+ Every poll it reads the board's opinions, the board's tasks, and the agents'
5
+ view records, and runs one tending pass (``tending.tend_candidates``):
6
+ promotions through ``create_tasks``, evaporations by declines, proposals and
7
+ corroborated observations turned into candidates, a promoted task's status
8
+ posted on its candidate's subject, one event per change in ``candidates.jsonl``.
9
+ On its first pass it may post candidates from a script, which is the model-free
10
+ stand-in for a planning model; a model coordinator posts through the same
11
+ :func:`marcus_toolkit.tending.post_candidate`. On SIGTERM or SIGINT it writes an
12
+ ``open_at_end`` row for every candidate still open and exits.
13
+
14
+ Usage::
15
+
16
+ python -m marcus_toolkit.tender --url http://127.0.0.1:PORT/mcp --project ID \\
17
+ --kanban-db data/kanban.db --state-dir runs/x/state \\
18
+ --out runs/x/candidates.jsonl \\
19
+ --crew 5 --post candidates-to-post.json
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import importlib
26
+ import json
27
+ import signal
28
+ import sqlite3
29
+ import sys
30
+ import time
31
+ from collections.abc import Callable, Sequence
32
+ from datetime import UTC, datetime
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from . import measure, tending, views
37
+ from .candidates import PLANNED_LABEL, can_post
38
+ from .client import MarcusMCPClient
39
+
40
+
41
+ def now_iso() -> str:
42
+ """Return the current UTC time in ISO-8601."""
43
+ return datetime.now(UTC).isoformat()
44
+
45
+
46
+ def board_tasks(kanban_db: Path, project_id: str) -> list[dict[str, Any]]:
47
+ """
48
+ Read a project's task rows off the board file.
49
+
50
+ Parameters
51
+ ----------
52
+ kanban_db : Path
53
+ The board.
54
+ project_id : str
55
+ The project.
56
+
57
+ Returns
58
+ -------
59
+ List[Dict[str, Any]]
60
+ Rows with ``id``, ``name``, ``status`` and ``assigned_to``; empty when
61
+ unreadable.
62
+ """
63
+ if not kanban_db.exists():
64
+ return []
65
+ try:
66
+ con = sqlite3.connect(f"file:{kanban_db}?mode=ro", uri=True, timeout=5)
67
+ except sqlite3.Error:
68
+ return []
69
+ try:
70
+ rows = con.execute(
71
+ "select id, name, status, assigned_to from tasks where project_id = ?",
72
+ (project_id,),
73
+ ).fetchall()
74
+ except sqlite3.Error:
75
+ return []
76
+ finally:
77
+ con.close()
78
+ return [
79
+ {"id": r[0], "name": r[1], "status": r[2], "assigned_to": r[3]} for r in rows
80
+ ]
81
+
82
+
83
+ class Tender:
84
+ """
85
+ One controller tending one project's candidates.
86
+
87
+ Parameters
88
+ ----------
89
+ client : Any
90
+ A connected board client with ``call_tool``.
91
+ project_id : str
92
+ The project tended.
93
+ controller_id : str
94
+ The controller's agent id.
95
+ crew : int
96
+ The cap on open candidates.
97
+ out : Path
98
+ ``candidates.jsonl``.
99
+ state_dir : Optional[Path]
100
+ Where the agents' ``board-calls.jsonl`` files are, for the view tally.
101
+ kanban_db : Optional[Path]
102
+ The board file, for task names and statuses.
103
+ labels : Sequence[str]
104
+ Labels proposal- and observation-born tasks carry.
105
+ declines_needed, settling_seconds, veto_seconds : int
106
+ The rule's knobs.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ client: Any,
112
+ project_id: str,
113
+ controller_id: str,
114
+ crew: int,
115
+ out: Path,
116
+ state_dir: Path | None = None,
117
+ kanban_db: Path | None = None,
118
+ labels: Sequence[str] = (PLANNED_LABEL,),
119
+ declines_needed: int = 3,
120
+ settling_seconds: int = 0,
121
+ veto_seconds: int = 120,
122
+ build_seed: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
123
+ no_evidence_neutral: bool = False,
124
+ observation_key: Callable[[dict[str, Any]], str | None] | None = None,
125
+ ) -> None:
126
+ self.build_seed = build_seed
127
+ self.no_evidence_neutral = no_evidence_neutral
128
+ self.observation_key = observation_key
129
+ self.client = client
130
+ self.project_id = project_id
131
+ self.controller_id = controller_id
132
+ self.crew = crew
133
+ self.out = out
134
+ self.state_dir = state_dir
135
+ self.kanban_db = kanban_db
136
+ self.labels = list(labels)
137
+ self.declines_needed = declines_needed
138
+ self.settling_seconds = settling_seconds
139
+ self.veto_seconds = veto_seconds
140
+ self.handled: set[str] = set()
141
+ self.answered: dict[str, list[str]] = {}
142
+ self.attempts: dict[str, int] = {}
143
+ self.last_open: list[dict[str, Any]] = []
144
+ self.last_views: dict[str, dict[str, Any]] | None = None
145
+ self.passes = 0
146
+
147
+ def entries(self) -> list[dict[str, Any]]:
148
+ """Read the board's opinions entries."""
149
+ reply = (
150
+ self.client.call_tool(
151
+ "query_project_history",
152
+ {"project_id": self.project_id, "query_type": "opinions", "limit": 200},
153
+ )
154
+ or {}
155
+ )
156
+ data = reply.get("data") if reply.get("success") is not False else None
157
+ return [e for e in (data or []) if isinstance(e, dict)]
158
+
159
+ def post_scripted(self, specs: list[dict[str, Any]]) -> list[str]:
160
+ """
161
+ Post candidates from a script, up to the cap.
162
+
163
+ Parameters
164
+ ----------
165
+ specs : List[Dict[str, Any]]
166
+ Task specs with ``because``.
167
+
168
+ Returns
169
+ -------
170
+ List[str]
171
+ The subjects posted.
172
+ """
173
+ posted: list[str] = []
174
+ open_count = len(self.last_open)
175
+ for spec in specs:
176
+ if not can_post(open_count, self.crew):
177
+ break
178
+ posted.append(
179
+ tending.post_candidate(
180
+ self.client, self.controller_id, spec, self.out, now_iso()
181
+ )
182
+ )
183
+ open_count += 1
184
+ return posted
185
+
186
+ def run_pass(self) -> dict[str, list[Any]]:
187
+ """Run one tending pass and return its actions."""
188
+ entries = self.entries()
189
+ authors = {
190
+ str(e.get("subject")): [str(a) for a in (e.get("agents") or [])]
191
+ for e in entries
192
+ if e.get("subject")
193
+ }
194
+ tasks = board_tasks(self.kanban_db, self.project_id) if self.kanban_db else []
195
+ tally = None
196
+ if self.state_dir is not None:
197
+ calls = measure.read_calls(self.state_dir)
198
+ tally = views.tally(calls)
199
+ actions = tending.tend_candidates(
200
+ self.client,
201
+ entries,
202
+ now=now_iso(),
203
+ crew=self.crew,
204
+ controller_id=self.controller_id,
205
+ out=self.out,
206
+ handled=self.handled,
207
+ authors=authors,
208
+ board_names=[str(t.get("name") or "") for t in tasks],
209
+ labels=self.labels,
210
+ veto_seconds=self.veto_seconds,
211
+ views=tally,
212
+ declines_needed=self.declines_needed,
213
+ settling_seconds=self.settling_seconds,
214
+ answered=self.answered,
215
+ attempts=self.attempts,
216
+ tasks=tasks,
217
+ observations=self.build_seed is not None,
218
+ build_seed=self.build_seed,
219
+ no_evidence_neutral=self.no_evidence_neutral,
220
+ observation_key=self.observation_key,
221
+ )
222
+ self.last_open = list(actions.get("open_specs") or [])
223
+ self.last_views = tally
224
+ self.passes += 1
225
+ return actions
226
+
227
+ def flush_open(self) -> int:
228
+ """Write an ``open_at_end`` row per candidate still open; return how many."""
229
+ rows = tending.open_at_end_records(
230
+ self.last_open, self.last_views, self.answered, now_iso()
231
+ )
232
+ if rows:
233
+ self.out.parent.mkdir(parents=True, exist_ok=True)
234
+ with self.out.open("a") as handle:
235
+ for row in rows:
236
+ handle.write(json.dumps(row) + "\n")
237
+ return len(rows)
238
+
239
+
240
+ def load_build_seed(spec: str) -> Callable[[dict[str, Any]], Any]:
241
+ """
242
+ Import ``module:function`` from the current path.
243
+
244
+ Parameters
245
+ ----------
246
+ spec : str
247
+ ``package.module:function``.
248
+
249
+ Returns
250
+ -------
251
+ Callable
252
+ The extension's observation rule.
253
+ """
254
+ module_name, _, func_name = spec.partition(":")
255
+ if not module_name or not func_name:
256
+ raise ValueError(f"--build-seed needs module:function, got {spec!r}")
257
+ module = importlib.import_module(module_name)
258
+ func = getattr(module, func_name)
259
+ if not callable(func):
260
+ raise ValueError(f"{spec} is not callable")
261
+ return func # type: ignore[no-any-return]
262
+
263
+
264
+ class Stopped(Exception):
265
+ """Raised by the signal handler to end the loop."""
266
+
267
+
268
+ def main(argv: list[str] | None = None) -> int:
269
+ """CLI entry point: tend until stopped or until the deadline."""
270
+ parser = argparse.ArgumentParser(description=__doc__)
271
+ parser.add_argument("--url", required=True)
272
+ parser.add_argument("--project", required=True)
273
+ parser.add_argument("--controller-id", default="planner")
274
+ parser.add_argument("--crew", type=int, default=5)
275
+ parser.add_argument("--out", required=True, help="candidates.jsonl")
276
+ parser.add_argument("--state-dir", default=None)
277
+ parser.add_argument("--kanban-db", default=None)
278
+ parser.add_argument(
279
+ "--post", default=None, help="a JSON list of specs to post on the first pass"
280
+ )
281
+ parser.add_argument("--labels", default=PLANNED_LABEL, help="comma-separated")
282
+ parser.add_argument(
283
+ "--build-seed",
284
+ default=None,
285
+ help="module:function turning a corroborated observation entry into a task spec"
286
+ " (or None for no card); turns the observation route on",
287
+ )
288
+ parser.add_argument("--poll-seconds", type=float, default=1.0)
289
+ parser.add_argument("--max-minutes", type=float, default=30.0)
290
+ parser.add_argument("--declines-needed", type=int, default=3)
291
+ parser.add_argument("--settling-seconds", type=int, default=0)
292
+ parser.add_argument("--veto-seconds", type=int, default=120)
293
+ parser.add_argument(
294
+ "--observation-key",
295
+ default=None,
296
+ help="module:function naming what two observation subjects share (a place);"
297
+ " entries with the same key are merged before the threshold",
298
+ )
299
+ parser.add_argument(
300
+ "--no-evidence-neutral",
301
+ action="store_true",
302
+ help="NO EVIDENCE is neither support nor a decline; only objections decline",
303
+ )
304
+ args = parser.parse_args(argv)
305
+
306
+ client = MarcusMCPClient(args.url, timeout=60)
307
+ if not client.connect():
308
+ print(f"tender: could not connect: {client.last_error}", file=sys.stderr)
309
+ return 2
310
+ if not tending.ensure_registered(client, args.controller_id, args.project):
311
+ print("tender: registration refused", file=sys.stderr)
312
+ return 2
313
+ build_seed = load_build_seed(args.build_seed) if args.build_seed else None
314
+ observation_key = (
315
+ load_build_seed(args.observation_key) if args.observation_key else None
316
+ )
317
+ tender = Tender(
318
+ client,
319
+ args.project,
320
+ args.controller_id,
321
+ args.crew,
322
+ Path(args.out),
323
+ state_dir=Path(args.state_dir) if args.state_dir else None,
324
+ kanban_db=Path(args.kanban_db) if args.kanban_db else None,
325
+ labels=[s.strip() for s in args.labels.split(",") if s.strip()],
326
+ declines_needed=args.declines_needed,
327
+ settling_seconds=args.settling_seconds,
328
+ veto_seconds=args.veto_seconds,
329
+ build_seed=build_seed,
330
+ no_evidence_neutral=args.no_evidence_neutral,
331
+ observation_key=observation_key,
332
+ )
333
+
334
+ def stop(signum: int, frame: Any) -> None:
335
+ raise Stopped()
336
+
337
+ signal.signal(signal.SIGTERM, stop)
338
+ signal.signal(signal.SIGINT, stop)
339
+ deadline = time.time() + args.max_minutes * 60
340
+ try:
341
+ if args.post:
342
+ specs = json.loads(Path(args.post).read_text())
343
+ posted = tender.post_scripted(list(specs))
344
+ print(f"tender: posted {len(posted)} scripted candidate(s)", flush=True)
345
+ while time.time() < deadline:
346
+ actions = tender.run_pass()
347
+ moved = {
348
+ k: v
349
+ for k, v in actions.items()
350
+ if v and k not in ("open_specs", "settled_names")
351
+ }
352
+ if moved:
353
+ print(f"tender: pass {tender.passes}: {json.dumps(moved)}", flush=True)
354
+ time.sleep(args.poll_seconds)
355
+ except Stopped:
356
+ pass
357
+ finally:
358
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
359
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
360
+ flushed = tender.flush_open()
361
+ print(
362
+ f"tender: stopped after {tender.passes} passes; {flushed} open at end",
363
+ flush=True,
364
+ )
365
+ return 0
366
+
367
+
368
+ if __name__ == "__main__":
369
+ sys.exit(main())