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/tender.py
ADDED
|
@@ -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())
|