marcus-toolkit 0.1.0__tar.gz

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.
Files changed (30) hide show
  1. marcus_toolkit-0.1.0/LICENSE +21 -0
  2. marcus_toolkit-0.1.0/PKG-INFO +98 -0
  3. marcus_toolkit-0.1.0/README.md +57 -0
  4. marcus_toolkit-0.1.0/marcus_toolkit/__init__.py +27 -0
  5. marcus_toolkit-0.1.0/marcus_toolkit/agent.py +451 -0
  6. marcus_toolkit-0.1.0/marcus_toolkit/candidates.py +489 -0
  7. marcus_toolkit-0.1.0/marcus_toolkit/client.py +319 -0
  8. marcus_toolkit-0.1.0/marcus_toolkit/funnel.py +154 -0
  9. marcus_toolkit-0.1.0/marcus_toolkit/gate.py +150 -0
  10. marcus_toolkit-0.1.0/marcus_toolkit/integrity.py +251 -0
  11. marcus_toolkit-0.1.0/marcus_toolkit/livecheck.py +223 -0
  12. marcus_toolkit-0.1.0/marcus_toolkit/measure.py +197 -0
  13. marcus_toolkit-0.1.0/marcus_toolkit/pricing.py +96 -0
  14. marcus_toolkit-0.1.0/marcus_toolkit/record.py +84 -0
  15. marcus_toolkit-0.1.0/marcus_toolkit/sampler.py +169 -0
  16. marcus_toolkit-0.1.0/marcus_toolkit/seed.py +189 -0
  17. marcus_toolkit-0.1.0/marcus_toolkit/server.py +348 -0
  18. marcus_toolkit-0.1.0/marcus_toolkit/tender.py +369 -0
  19. marcus_toolkit-0.1.0/marcus_toolkit/tending.py +946 -0
  20. marcus_toolkit-0.1.0/marcus_toolkit/usage.py +222 -0
  21. marcus_toolkit-0.1.0/marcus_toolkit/views.py +150 -0
  22. marcus_toolkit-0.1.0/marcus_toolkit/watchdog.py +264 -0
  23. marcus_toolkit-0.1.0/marcus_toolkit.egg-info/PKG-INFO +98 -0
  24. marcus_toolkit-0.1.0/marcus_toolkit.egg-info/SOURCES.txt +28 -0
  25. marcus_toolkit-0.1.0/marcus_toolkit.egg-info/dependency_links.txt +1 -0
  26. marcus_toolkit-0.1.0/marcus_toolkit.egg-info/entry_points.txt +5 -0
  27. marcus_toolkit-0.1.0/marcus_toolkit.egg-info/requires.txt +6 -0
  28. marcus_toolkit-0.1.0/marcus_toolkit.egg-info/top_level.txt +1 -0
  29. marcus_toolkit-0.1.0/pyproject.toml +55 -0
  30. marcus_toolkit-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Larry Gray
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: marcus-toolkit
3
+ Version: 0.1.0
4
+ Summary: The run-side toolbox Marcus extensions share: a server under test, seeding, run guards, the cost watchdog, the board sampler, measures, the run record, and a pull-loop agent shell
5
+ License: MIT License
6
+
7
+ Copyright (c) 2025 Larry Gray
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/painted-porch/marcus-toolkit
28
+ Project-URL: Changelog, https://github.com/painted-porch/marcus-toolkit/blob/main/CHANGELOG.md
29
+ Classifier: License :: OSI Approved :: MIT License
30
+ Classifier: Programming Language :: Python :: 3.11
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Requires-Python: >=3.11
33
+ Description-Content-Type: text/markdown
34
+ License-File: LICENSE
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest==7.4.3; extra == "dev"
37
+ Requires-Dist: mypy==1.19.1; extra == "dev"
38
+ Requires-Dist: ruff==0.15.5; extra == "dev"
39
+ Requires-Dist: black==26.3.0; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ # marcus-toolkit
43
+
44
+ The run-side toolbox that Marcus extensions share.
45
+
46
+ ## What this is, briefly
47
+
48
+ [Marcus](https://github.com/painted-porch/marcus) coordinates AI agents through a shared task board: agents pull work from the board over MCP (the Model Context Protocol, the wire protocol for Marcus's tools), do it, and report back with evidence.
49
+ An *extension* is a separate program that seeds a board and runs agents against it for one purpose: verifying citations, verifying a numerical report, coordinating Minecraft agents, refereeing a wargame.
50
+
51
+ Every extension needs the same scaffolding around a run, and every extension so far rebuilt it.
52
+ This package is that scaffolding, built once (the inventory and the reasoning are in painted-porch/marcus issue #914):
53
+
54
+ | Module | What it does |
55
+ | --- | --- |
56
+ | `marcus_toolkit.server` | a **server under test**: a private Marcus started for one run with every model-provider key stripped from its environment and the model switched off with an empty key, so the run can spend nothing by construction; refuses a port someone else holds and proves the port's owner is this run's server |
57
+ | `marcus_toolkit.seed` | seeds a project with exactly the tasks named, through `create_project(options.tasks)`, and refuses before the call what the board would mis-handle: an unknown spec key, a `build`-family label, a dependency naming no task, a project name containing "test" |
58
+ | `marcus_toolkit.watchdog` | stops a run the moment its spend reaches the approved line; the line is re-read on every poll when it comes from a file |
59
+ | `marcus_toolkit.pricing`, `marcus_toolkit.usage` | one price table; usage rows read from pi's transcripts or from `<state>/<agent>/usage.jsonl`, one row per model call, which any runtime can write |
60
+ | `marcus_toolkit.sampler` | reads the board from outside every few seconds and applies the drain verdict's claimability rule, so frontier width has a time series |
61
+ | `marcus_toolkit.integrity` | hashes the files that execute, hashes the same files as committed, records a checkout's version |
62
+ | `marcus_toolkit.record` | the run record: `runs/<stamp>/`, `run-config.txt`, JSONL streams, copies |
63
+ | `marcus_toolkit.measure` | pulls and the idle-poll ratio, the kernel's frontier series, the sampler's series, board counts from a copy of `kanban.db` |
64
+ | `marcus_toolkit.client`, `marcus_toolkit.agent` | the MCP client and a pull-loop agent shell in Python |
65
+ | `node/` | the same client in Node: `mcp-http.js` and `board.js` |
66
+ | `marcus_toolkit.livecheck` | the toolbox's own live check against a real Marcus checkout |
67
+
68
+ Nothing in this package imports Marcus's own code.
69
+ It talks to a server over MCP and reads the files a run leaves behind, which is all an extension may do (`docs/architecture/extension-contract.md` in the Marcus repository).
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ pip install -e . # from a checkout
75
+ pip install -e ".[dev]" # with the test and lint tools
76
+ ```
77
+
78
+ For the Node client, import `node/board.js` and `node/mcp-http.js` directly; they have no dependencies.
79
+
80
+ ## The live check
81
+
82
+ ```bash
83
+ python -m marcus_toolkit.livecheck --checkout ~/dev/marcus --out livecheck-runs
84
+ ```
85
+
86
+ Starts a server under test from that checkout, seeds two tasks (the second depending on the first), runs one scripted agent through register, pull, report, pull, report, drain, reads the board from outside before and after, and writes `livecheck-runs/<stamp>/VERDICT.json`.
87
+ It passes only when every card closed, the agent heard the drain, every reply was read back, and the server log carries no model-provider host.
88
+ CI runs it against the Marcus commit pinned in `.github/workflows/ci.yml`.
89
+
90
+ ## Where it came from
91
+
92
+ The Minecraft harness's plumbing (`painted-porch/marcus-minecraft`, `plumbing/`), the Marcus experiment runners (`dev-tools/experiments/runners/marcus_client.py`), and the AI-citation verifier's free rehearsal.
93
+ The defects those runs found are fixed here once: a stop line read only at process start, a placeholder key that fired one real call on startup, a task spec whose extra key broke every promotion, a blocked task counted as claimable.
94
+
95
+ ## Versioning
96
+
97
+ Each release states the Marcus commit it was live-checked against.
98
+ The toolbox depends on Marcus's MCP tool names and reply shapes, not on its code; when those change, this package gets a release, not a patch to the kernel.
@@ -0,0 +1,57 @@
1
+ # marcus-toolkit
2
+
3
+ The run-side toolbox that Marcus extensions share.
4
+
5
+ ## What this is, briefly
6
+
7
+ [Marcus](https://github.com/painted-porch/marcus) coordinates AI agents through a shared task board: agents pull work from the board over MCP (the Model Context Protocol, the wire protocol for Marcus's tools), do it, and report back with evidence.
8
+ An *extension* is a separate program that seeds a board and runs agents against it for one purpose: verifying citations, verifying a numerical report, coordinating Minecraft agents, refereeing a wargame.
9
+
10
+ Every extension needs the same scaffolding around a run, and every extension so far rebuilt it.
11
+ This package is that scaffolding, built once (the inventory and the reasoning are in painted-porch/marcus issue #914):
12
+
13
+ | Module | What it does |
14
+ | --- | --- |
15
+ | `marcus_toolkit.server` | a **server under test**: a private Marcus started for one run with every model-provider key stripped from its environment and the model switched off with an empty key, so the run can spend nothing by construction; refuses a port someone else holds and proves the port's owner is this run's server |
16
+ | `marcus_toolkit.seed` | seeds a project with exactly the tasks named, through `create_project(options.tasks)`, and refuses before the call what the board would mis-handle: an unknown spec key, a `build`-family label, a dependency naming no task, a project name containing "test" |
17
+ | `marcus_toolkit.watchdog` | stops a run the moment its spend reaches the approved line; the line is re-read on every poll when it comes from a file |
18
+ | `marcus_toolkit.pricing`, `marcus_toolkit.usage` | one price table; usage rows read from pi's transcripts or from `<state>/<agent>/usage.jsonl`, one row per model call, which any runtime can write |
19
+ | `marcus_toolkit.sampler` | reads the board from outside every few seconds and applies the drain verdict's claimability rule, so frontier width has a time series |
20
+ | `marcus_toolkit.integrity` | hashes the files that execute, hashes the same files as committed, records a checkout's version |
21
+ | `marcus_toolkit.record` | the run record: `runs/<stamp>/`, `run-config.txt`, JSONL streams, copies |
22
+ | `marcus_toolkit.measure` | pulls and the idle-poll ratio, the kernel's frontier series, the sampler's series, board counts from a copy of `kanban.db` |
23
+ | `marcus_toolkit.client`, `marcus_toolkit.agent` | the MCP client and a pull-loop agent shell in Python |
24
+ | `node/` | the same client in Node: `mcp-http.js` and `board.js` |
25
+ | `marcus_toolkit.livecheck` | the toolbox's own live check against a real Marcus checkout |
26
+
27
+ Nothing in this package imports Marcus's own code.
28
+ It talks to a server over MCP and reads the files a run leaves behind, which is all an extension may do (`docs/architecture/extension-contract.md` in the Marcus repository).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install -e . # from a checkout
34
+ pip install -e ".[dev]" # with the test and lint tools
35
+ ```
36
+
37
+ For the Node client, import `node/board.js` and `node/mcp-http.js` directly; they have no dependencies.
38
+
39
+ ## The live check
40
+
41
+ ```bash
42
+ python -m marcus_toolkit.livecheck --checkout ~/dev/marcus --out livecheck-runs
43
+ ```
44
+
45
+ Starts a server under test from that checkout, seeds two tasks (the second depending on the first), runs one scripted agent through register, pull, report, pull, report, drain, reads the board from outside before and after, and writes `livecheck-runs/<stamp>/VERDICT.json`.
46
+ It passes only when every card closed, the agent heard the drain, every reply was read back, and the server log carries no model-provider host.
47
+ CI runs it against the Marcus commit pinned in `.github/workflows/ci.yml`.
48
+
49
+ ## Where it came from
50
+
51
+ The Minecraft harness's plumbing (`painted-porch/marcus-minecraft`, `plumbing/`), the Marcus experiment runners (`dev-tools/experiments/runners/marcus_client.py`), and the AI-citation verifier's free rehearsal.
52
+ The defects those runs found are fixed here once: a stop line read only at process start, a placeholder key that fired one real call on startup, a task spec whose extra key broke every promotion, a blocked task counted as claimable.
53
+
54
+ ## Versioning
55
+
56
+ Each release states the Marcus commit it was live-checked against.
57
+ The toolbox depends on Marcus's MCP tool names and reply shapes, not on its code; when those change, this package gets a release, not a patch to the kernel.
@@ -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"]