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.
- marcus_toolkit-0.1.0/LICENSE +21 -0
- marcus_toolkit-0.1.0/PKG-INFO +98 -0
- marcus_toolkit-0.1.0/README.md +57 -0
- marcus_toolkit-0.1.0/marcus_toolkit/__init__.py +27 -0
- marcus_toolkit-0.1.0/marcus_toolkit/agent.py +451 -0
- marcus_toolkit-0.1.0/marcus_toolkit/candidates.py +489 -0
- marcus_toolkit-0.1.0/marcus_toolkit/client.py +319 -0
- marcus_toolkit-0.1.0/marcus_toolkit/funnel.py +154 -0
- marcus_toolkit-0.1.0/marcus_toolkit/gate.py +150 -0
- marcus_toolkit-0.1.0/marcus_toolkit/integrity.py +251 -0
- marcus_toolkit-0.1.0/marcus_toolkit/livecheck.py +223 -0
- marcus_toolkit-0.1.0/marcus_toolkit/measure.py +197 -0
- marcus_toolkit-0.1.0/marcus_toolkit/pricing.py +96 -0
- marcus_toolkit-0.1.0/marcus_toolkit/record.py +84 -0
- marcus_toolkit-0.1.0/marcus_toolkit/sampler.py +169 -0
- marcus_toolkit-0.1.0/marcus_toolkit/seed.py +189 -0
- marcus_toolkit-0.1.0/marcus_toolkit/server.py +348 -0
- marcus_toolkit-0.1.0/marcus_toolkit/tender.py +369 -0
- marcus_toolkit-0.1.0/marcus_toolkit/tending.py +946 -0
- marcus_toolkit-0.1.0/marcus_toolkit/usage.py +222 -0
- marcus_toolkit-0.1.0/marcus_toolkit/views.py +150 -0
- marcus_toolkit-0.1.0/marcus_toolkit/watchdog.py +264 -0
- marcus_toolkit-0.1.0/marcus_toolkit.egg-info/PKG-INFO +98 -0
- marcus_toolkit-0.1.0/marcus_toolkit.egg-info/SOURCES.txt +28 -0
- marcus_toolkit-0.1.0/marcus_toolkit.egg-info/dependency_links.txt +1 -0
- marcus_toolkit-0.1.0/marcus_toolkit.egg-info/entry_points.txt +5 -0
- marcus_toolkit-0.1.0/marcus_toolkit.egg-info/requires.txt +6 -0
- marcus_toolkit-0.1.0/marcus_toolkit.egg-info/top_level.txt +1 -0
- marcus_toolkit-0.1.0/pyproject.toml +55 -0
- 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"]
|