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
|
@@ -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
|
+
]
|
marcus_toolkit/agent.py
ADDED
|
@@ -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"]
|