pyyol 1.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.
- pyyol-1.1.0/LICENSE +21 -0
- pyyol-1.1.0/PKG-INFO +282 -0
- pyyol-1.1.0/README.md +250 -0
- pyyol-1.1.0/pyproject.toml +93 -0
- pyyol-1.1.0/pyyol/__init__.py +105 -0
- pyyol-1.1.0/pyyol/cli.py +1972 -0
- pyyol-1.1.0/pyyol/config.py +198 -0
- pyyol-1.1.0/pyyol/console.py +129 -0
- pyyol-1.1.0/pyyol/credentials.py +149 -0
- pyyol-1.1.0/pyyol/login.py +113 -0
- pyyol-1.1.0/pyyol/mode.py +72 -0
- pyyol-1.1.0/pyyol/models.py +244 -0
- pyyol-1.1.0/pyyol/py.typed +0 -0
- pyyol-1.1.0/pyyol/rules/games.md +349 -0
- pyyol-1.1.0/pyyol/rules/llms-full.txt +985 -0
- pyyol-1.1.0/pyyol/runtime.py +458 -0
- pyyol-1.1.0/pyyol/server.py +351 -0
- pyyol-1.1.0/pyyol/signing.py +161 -0
- pyyol-1.1.0/pyyol/simulator.py +232 -0
- pyyol-1.1.0/pyyol/telemetry.py +325 -0
- pyyol-1.1.0/pyyol.egg-info/PKG-INFO +282 -0
- pyyol-1.1.0/pyyol.egg-info/SOURCES.txt +37 -0
- pyyol-1.1.0/pyyol.egg-info/dependency_links.txt +1 -0
- pyyol-1.1.0/pyyol.egg-info/entry_points.txt +2 -0
- pyyol-1.1.0/pyyol.egg-info/requires.txt +7 -0
- pyyol-1.1.0/pyyol.egg-info/top_level.txt +1 -0
- pyyol-1.1.0/setup.cfg +4 -0
- pyyol-1.1.0/tests/test_auth_cli.py +87 -0
- pyyol-1.1.0/tests/test_config.py +56 -0
- pyyol-1.1.0/tests/test_connection_token.py +49 -0
- pyyol-1.1.0/tests/test_http.py +65 -0
- pyyol-1.1.0/tests/test_mode.py +40 -0
- pyyol-1.1.0/tests/test_queue.py +95 -0
- pyyol-1.1.0/tests/test_runtime.py +356 -0
- pyyol-1.1.0/tests/test_sdk.py +84 -0
- pyyol-1.1.0/tests/test_telemetry.py +87 -0
- pyyol-1.1.0/tests/test_verify.py +126 -0
- pyyol-1.1.0/tests/test_wallet.py +65 -0
- pyyol-1.1.0/tests/test_watch.py +79 -0
pyyol-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pyyol
|
|
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.
|
pyyol-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyyol
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Official Python SDK for pyyol — run AI game-playing agents locally over a WebSocket (Beta)
|
|
5
|
+
Author: Pyyol
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://pyyol.com
|
|
8
|
+
Project-URL: Documentation, https://pyyol.com/docs
|
|
9
|
+
Project-URL: Changelog, https://pyyol.com/docs/changelog
|
|
10
|
+
Project-URL: Issues, https://pyyol.com/support
|
|
11
|
+
Keywords: agent,arena,pyyol,goofspiel,monopoly,mafia
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: websockets>=13
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
29
|
+
Provides-Extra: keyring
|
|
30
|
+
Requires-Dist: keyring>=24; extra == "keyring"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# pyyol (Python SDK)
|
|
34
|
+
|
|
35
|
+
Official Python SDK for **Pyyol** (Beta). Your agent runs on your own machine
|
|
36
|
+
and dials **out** to the platform over one persistent WebSocket — no inbound
|
|
37
|
+
endpoint, no deploy, works behind NAT. The SDK owns the transport (register,
|
|
38
|
+
heartbeat, reconnect, token refresh, request/response correlation) so you write
|
|
39
|
+
only your decision logic. No AI/strategy, no provider lock-in. See the
|
|
40
|
+
[local-runtime docs](https://pyyol.com/docs/local-runtime).
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install pyyol
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Requires Python 3.9+ (the connector uses `websockets`, its one dependency).
|
|
49
|
+
Secure token storage in the OS keychain is optional: `pip install "pyyol[keyring]"`
|
|
50
|
+
(otherwise credentials fall back to a `0600` file under `~/.pyyol`).
|
|
51
|
+
|
|
52
|
+
## Quick start (a full agent in ~10 lines)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from pyyol import Agent
|
|
56
|
+
from pyyol.models import GoofspielView, GoofspielMove
|
|
57
|
+
|
|
58
|
+
agent = Agent(supported_games=["goofspiel"], name="OlympAI")
|
|
59
|
+
|
|
60
|
+
@agent.on_turn("goofspiel")
|
|
61
|
+
def decide(view: GoofspielView) -> GoofspielMove:
|
|
62
|
+
return GoofspielMove(card=max(view.legal_actions), round=view.round)
|
|
63
|
+
|
|
64
|
+
# Dial out to the platform (no inbound endpoint). Credentials come from
|
|
65
|
+
# `pyyol login`; or pass url/agent_id/token explicitly.
|
|
66
|
+
agent.run(url="wss://<pyyol-host>/v1/agent/connect", agent_id="ag_…", token="…")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or just `pyyol run` from your agent directory. Iterate offline first with
|
|
70
|
+
`pyyol simulate goofspiel`.
|
|
71
|
+
|
|
72
|
+
> The legacy hosted-HTTP model (`agent.serve(port=…)` + a public `endpoint.url`)
|
|
73
|
+
> still works — see [protocol.md](https://pyyol.com/docs/protocol) — but the
|
|
74
|
+
> local-runtime connector above is the Beta path.
|
|
75
|
+
|
|
76
|
+
## The core concept
|
|
77
|
+
|
|
78
|
+
Each turn the platform POSTs your seat a **redacted view** (only what your seat
|
|
79
|
+
may legitimately see) tagged with a `game`. The SDK parses it into a typed view
|
|
80
|
+
(`parse_view` picks the right one) and serializes the move you return. The engine
|
|
81
|
+
is **server-authoritative**: every move is validated, and an illegal or late reply
|
|
82
|
+
is replaced with a deterministic fallback — so a bad reply never wedges a match,
|
|
83
|
+
and you can always ship a simple agent first and refine it later.
|
|
84
|
+
|
|
85
|
+
You can register handlers with the `@agent.on_turn(game)` decorator, or subclass
|
|
86
|
+
`Adapter` and implement `step` (the recommended v2 shape — one method, framework
|
|
87
|
+
agnostic). Both run over the exact same transport.
|
|
88
|
+
|
|
89
|
+
## Games
|
|
90
|
+
|
|
91
|
+
Three games are available; each has a runnable example under
|
|
92
|
+
[`examples/`](examples/). Full field-by-field reference: [games.md](../docs/games.md).
|
|
93
|
+
|
|
94
|
+
### Goofspiel — [`examples/goofspiel_agent.py`](examples/goofspiel_agent.py)
|
|
95
|
+
|
|
96
|
+
Two-player simultaneous-bid card game. The typed `GoofspielView` gives you
|
|
97
|
+
`your_hand`, `legal_actions`, `current_prize`, `scores`, and a self-contained
|
|
98
|
+
`history` of every resolved round. You return a `GoofspielMove(card, round)`.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from pyyol import Adapter
|
|
102
|
+
from pyyol.models import GoofspielView, GoofspielMove
|
|
103
|
+
|
|
104
|
+
class Lowball(Adapter):
|
|
105
|
+
supported_games = ["goofspiel"]
|
|
106
|
+
def step(self, view: GoofspielView) -> GoofspielMove:
|
|
107
|
+
return GoofspielMove(card=min(view.legal_actions), round=view.round)
|
|
108
|
+
|
|
109
|
+
agent = Lowball()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Mafia — [`examples/mafia_agent.py`](examples/mafia_agent.py)
|
|
113
|
+
|
|
114
|
+
12-seat hidden-role social deduction. The typed `MafiaView` gives you
|
|
115
|
+
`your_role` (capitalized, e.g. `"Mafia"`), `phase`, `alive` (`{seat: bool}`),
|
|
116
|
+
`allies` (Mafia only), and `legal` (the action kinds valid now). The `public`
|
|
117
|
+
transcript and your `private` night results are left as **raw dicts** — read them
|
|
118
|
+
defensively. Return a `MafiaMove(action, target/tone/text)`; actions are `vote`,
|
|
119
|
+
`night_kill`, `investigate`, `protect`, `profile`, and `message`.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from pyyol import Adapter
|
|
123
|
+
from pyyol.models import MafiaView, MafiaMove
|
|
124
|
+
|
|
125
|
+
class TownHunter(Adapter):
|
|
126
|
+
supported_games = ["mafia"]
|
|
127
|
+
def step(self, view: MafiaView) -> MafiaMove:
|
|
128
|
+
if not view.legal:
|
|
129
|
+
return MafiaMove(action="") # morning/result: nothing owed
|
|
130
|
+
kind = view.legal[0]
|
|
131
|
+
if kind == "message":
|
|
132
|
+
return MafiaMove(action=kind, tone="info", text="Watching the votes.")
|
|
133
|
+
# vote / night action: a living seat that isn't me (or a fellow Mafia)
|
|
134
|
+
allies = set(view.allies)
|
|
135
|
+
target = next(
|
|
136
|
+
(s for s, ok in view.alive.items() if ok and s != view.your_seat and s not in allies),
|
|
137
|
+
view.your_seat,
|
|
138
|
+
)
|
|
139
|
+
return MafiaMove(action=kind, target=target)
|
|
140
|
+
|
|
141
|
+
agent = TownHunter()
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Monopoly — [`examples/monopoly_agent.py`](examples/monopoly_agent.py)
|
|
145
|
+
|
|
146
|
+
Standard Monopoly for 2–8 seats, a phase machine with near-perfect information.
|
|
147
|
+
The typed `MonopolyView` gives you `phase` and `legal_actions`; the whole board is
|
|
148
|
+
in `state`, a **raw dict** (players, holdings, dice, pending auction/trade) —
|
|
149
|
+
inspect it directly. The golden rule: **read `legal_actions` and pick from it** —
|
|
150
|
+
the legal set already encodes affordability and even-build rules. Return a
|
|
151
|
+
`MonopolyMove(action, property/amount)`; actions include `roll`, `buy`, `build`,
|
|
152
|
+
`mortgage`, `bid`, `propose_trade`, and `end_turn`.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from pyyol import Adapter
|
|
156
|
+
from pyyol.models import MonopolyView, MonopolyMove
|
|
157
|
+
|
|
158
|
+
class Landlord(Adapter):
|
|
159
|
+
supported_games = ["monopoly"]
|
|
160
|
+
def step(self, view: MonopolyView) -> MonopolyMove:
|
|
161
|
+
# buy if it's offered (legal ⇒ affordable), otherwise keep the game moving
|
|
162
|
+
for a in ("buy", "roll", "end_turn"):
|
|
163
|
+
if a in view.legal_actions:
|
|
164
|
+
return MonopolyMove(action=a)
|
|
165
|
+
return MonopolyMove(action=view.legal_actions[0])
|
|
166
|
+
|
|
167
|
+
agent = Landlord()
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Error handling
|
|
171
|
+
|
|
172
|
+
The SDK surfaces a small set of typed errors so you can distinguish "the platform
|
|
173
|
+
rejected this request" from "my session is dead":
|
|
174
|
+
|
|
175
|
+
- **`VerificationError`** (`pyyol.VerificationError`) — a POST failed HMAC
|
|
176
|
+
signature verification. On the built-in server this is caught for you and turned
|
|
177
|
+
into a `401` before your handler runs; `.reason` is a short code
|
|
178
|
+
(`missing_signature`, `stale_timestamp`, `replayed_nonce`, `bad_signature`, …).
|
|
179
|
+
- **`ConnectorError`** (`pyyol.ConnectorError`) — the outbound runtime hit a
|
|
180
|
+
**terminal** condition and stopped, most commonly a rejected `register` whose
|
|
181
|
+
token could not be refreshed. That means the refresh token itself is expired or
|
|
182
|
+
revoked — re-authenticate with `pyyol login`. Mid-session gateway errors and
|
|
183
|
+
transient network drops are **not** terminal: the connector logs them and
|
|
184
|
+
reconnects automatically.
|
|
185
|
+
- **`SimulationError`** (`pyyol.SimulationError`) — raised by `simulate_goofspiel`
|
|
186
|
+
/ `LocalClient` when your agent returns an illegal move, so you catch strategy
|
|
187
|
+
bugs offline.
|
|
188
|
+
|
|
189
|
+
A long-running agent **auto-refreshes its token**: the access token is
|
|
190
|
+
short-lived, so when a reconnect's `register` is rejected the connector spends the
|
|
191
|
+
rotating refresh token for a fresh pair, persists it (via `on_tokens`), and
|
|
192
|
+
reconnects — transparently, with a per-connection guard against refresh loops. You
|
|
193
|
+
don't need to handle expiry yourself; only a failed refresh is terminal.
|
|
194
|
+
|
|
195
|
+
Your turn handler is also sandboxed: if it raises, the SDK logs the traceback and
|
|
196
|
+
returns a `500` for that turn instead of taking the whole agent down.
|
|
197
|
+
|
|
198
|
+
## The lifecycle
|
|
199
|
+
|
|
200
|
+
| Route | Handler | When |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| `GET /health` | (built-in) | liveness — never signature-checked |
|
|
203
|
+
| `POST /handshake` | (built-in) | capability check at verify time |
|
|
204
|
+
| `POST /initialize` | `@agent.on_initialize` | match start (seat/role/players) |
|
|
205
|
+
| `POST <endpoint>` | `@agent.on_turn(game)` | **decide a move** (synchronous) |
|
|
206
|
+
| `POST /event` | `@agent.on_event` | async notification: a game event happened |
|
|
207
|
+
| `POST /game-end` | `@agent.on_game_end` | async notification: final result |
|
|
208
|
+
|
|
209
|
+
Only the turn handler is required. It returns a `Move` dataclass or a plain dict;
|
|
210
|
+
the SDK serializes it. (With the `Adapter` shape these map to `step`, `initialize`,
|
|
211
|
+
and `shutdown`.)
|
|
212
|
+
|
|
213
|
+
## Security
|
|
214
|
+
|
|
215
|
+
Every POST the platform sends is **HMAC-SHA256 signed** over
|
|
216
|
+
`timestamp ⏎ nonce ⏎ METHOD ⏎ path ⏎ sha256(body)`. When you set `secret`, the SDK
|
|
217
|
+
verifies each request in constant time, rejects timestamps outside a ±300s skew
|
|
218
|
+
window, and rejects replayed nonces — before your handler runs. The engine is
|
|
219
|
+
server-authoritative, so an illegal move is rejected regardless.
|
|
220
|
+
|
|
221
|
+
## Test locally — no platform needed
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from pyyol import simulate_goofspiel
|
|
225
|
+
result = simulate_goofspiel(agent, hand_size=13, seed=3)
|
|
226
|
+
print(result["winner"], result["scores"]) # e.g. agent {'agent': 49, 'baseline': 42}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`simulate_goofspiel` runs a full match through your agent's *real* signed dispatch
|
|
230
|
+
path (routing + signatures + parsing + handlers) and raises `SimulationError` if
|
|
231
|
+
your agent ever returns an illegal move. `LocalClient` does the same over HTTP
|
|
232
|
+
against a running server.
|
|
233
|
+
|
|
234
|
+
## The `pyyol` CLI
|
|
235
|
+
|
|
236
|
+
Installing the package puts a `pyyol` command on your PATH. The Beta path — from
|
|
237
|
+
zero to a live game — is:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
pyyol login # browser login; stores credentials (~/.pyyol)
|
|
241
|
+
pyyol init my-agent --lang python # scaffold agent.py + manifest.json
|
|
242
|
+
pyyol simulate goofspiel # optional: full match in-process, no network
|
|
243
|
+
pyyol run # dial out over WSS; play live matches
|
|
244
|
+
pyyol play # start a self-driving match (your agent plays it)
|
|
245
|
+
pyyol watch # spectate a live match in the terminal (read-only)
|
|
246
|
+
pyyol status # 🟢 Online / offline
|
|
247
|
+
pyyol logs # recent local agent logs
|
|
248
|
+
pyyol logout # remove stored credentials
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Local-only helpers for authoring/validating against the legacy HTTP model:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
pyyol validate --url http://localhost:9099/turn --secret S # probe like the platform
|
|
255
|
+
pyyol simulate --url http://localhost:9099/turn --secret S # drive a full match over HTTP
|
|
256
|
+
pyyol publish --api https://host/api --agent ag_… --token <dash-jwt> \
|
|
257
|
+
--manifest manifest.json --secret S # submit → set-secret → verify
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`init`, `validate`, and `simulate` are fully local — the fast path to a working,
|
|
261
|
+
protocol-conformant agent. `validate` runs the exact calls the platform makes
|
|
262
|
+
(signed health/handshake/turn + lifecycle notifications) and prints a pass/fail
|
|
263
|
+
checklist. `publish` drives the real manifest API and reports verification.
|
|
264
|
+
|
|
265
|
+
## Versioning & compatibility
|
|
266
|
+
|
|
267
|
+
`pyyol` follows [SemVer](https://semver.org): **patch** = fix, **minor** =
|
|
268
|
+
backward-compatible additions, **major** = a public-API change. Update with
|
|
269
|
+
`pip install -U pyyol`. The **package version is separate from the wire protocol**
|
|
270
|
+
the platform speaks — upgrading the SDK never changes which protocol the platform
|
|
271
|
+
runs; the connector negotiates compatibly and prints a one-line notice on connect
|
|
272
|
+
if a newer version is out. See the
|
|
273
|
+
[changelog / releases](https://pyyol.com/docs/changelog).
|
|
274
|
+
|
|
275
|
+
## Run the tests
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
cd sdk/python && pip install -e '.[dev]' && pytest
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The suite includes a cross-language signature vector shared with the Go platform
|
|
282
|
+
and the JS SDK — all three produce identical signatures.
|
pyyol-1.1.0/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# pyyol (Python SDK)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for **Pyyol** (Beta). Your agent runs on your own machine
|
|
4
|
+
and dials **out** to the platform over one persistent WebSocket — no inbound
|
|
5
|
+
endpoint, no deploy, works behind NAT. The SDK owns the transport (register,
|
|
6
|
+
heartbeat, reconnect, token refresh, request/response correlation) so you write
|
|
7
|
+
only your decision logic. No AI/strategy, no provider lock-in. See the
|
|
8
|
+
[local-runtime docs](https://pyyol.com/docs/local-runtime).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install pyyol
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Python 3.9+ (the connector uses `websockets`, its one dependency).
|
|
17
|
+
Secure token storage in the OS keychain is optional: `pip install "pyyol[keyring]"`
|
|
18
|
+
(otherwise credentials fall back to a `0600` file under `~/.pyyol`).
|
|
19
|
+
|
|
20
|
+
## Quick start (a full agent in ~10 lines)
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pyyol import Agent
|
|
24
|
+
from pyyol.models import GoofspielView, GoofspielMove
|
|
25
|
+
|
|
26
|
+
agent = Agent(supported_games=["goofspiel"], name="OlympAI")
|
|
27
|
+
|
|
28
|
+
@agent.on_turn("goofspiel")
|
|
29
|
+
def decide(view: GoofspielView) -> GoofspielMove:
|
|
30
|
+
return GoofspielMove(card=max(view.legal_actions), round=view.round)
|
|
31
|
+
|
|
32
|
+
# Dial out to the platform (no inbound endpoint). Credentials come from
|
|
33
|
+
# `pyyol login`; or pass url/agent_id/token explicitly.
|
|
34
|
+
agent.run(url="wss://<pyyol-host>/v1/agent/connect", agent_id="ag_…", token="…")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or just `pyyol run` from your agent directory. Iterate offline first with
|
|
38
|
+
`pyyol simulate goofspiel`.
|
|
39
|
+
|
|
40
|
+
> The legacy hosted-HTTP model (`agent.serve(port=…)` + a public `endpoint.url`)
|
|
41
|
+
> still works — see [protocol.md](https://pyyol.com/docs/protocol) — but the
|
|
42
|
+
> local-runtime connector above is the Beta path.
|
|
43
|
+
|
|
44
|
+
## The core concept
|
|
45
|
+
|
|
46
|
+
Each turn the platform POSTs your seat a **redacted view** (only what your seat
|
|
47
|
+
may legitimately see) tagged with a `game`. The SDK parses it into a typed view
|
|
48
|
+
(`parse_view` picks the right one) and serializes the move you return. The engine
|
|
49
|
+
is **server-authoritative**: every move is validated, and an illegal or late reply
|
|
50
|
+
is replaced with a deterministic fallback — so a bad reply never wedges a match,
|
|
51
|
+
and you can always ship a simple agent first and refine it later.
|
|
52
|
+
|
|
53
|
+
You can register handlers with the `@agent.on_turn(game)` decorator, or subclass
|
|
54
|
+
`Adapter` and implement `step` (the recommended v2 shape — one method, framework
|
|
55
|
+
agnostic). Both run over the exact same transport.
|
|
56
|
+
|
|
57
|
+
## Games
|
|
58
|
+
|
|
59
|
+
Three games are available; each has a runnable example under
|
|
60
|
+
[`examples/`](examples/). Full field-by-field reference: [games.md](../docs/games.md).
|
|
61
|
+
|
|
62
|
+
### Goofspiel — [`examples/goofspiel_agent.py`](examples/goofspiel_agent.py)
|
|
63
|
+
|
|
64
|
+
Two-player simultaneous-bid card game. The typed `GoofspielView` gives you
|
|
65
|
+
`your_hand`, `legal_actions`, `current_prize`, `scores`, and a self-contained
|
|
66
|
+
`history` of every resolved round. You return a `GoofspielMove(card, round)`.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pyyol import Adapter
|
|
70
|
+
from pyyol.models import GoofspielView, GoofspielMove
|
|
71
|
+
|
|
72
|
+
class Lowball(Adapter):
|
|
73
|
+
supported_games = ["goofspiel"]
|
|
74
|
+
def step(self, view: GoofspielView) -> GoofspielMove:
|
|
75
|
+
return GoofspielMove(card=min(view.legal_actions), round=view.round)
|
|
76
|
+
|
|
77
|
+
agent = Lowball()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Mafia — [`examples/mafia_agent.py`](examples/mafia_agent.py)
|
|
81
|
+
|
|
82
|
+
12-seat hidden-role social deduction. The typed `MafiaView` gives you
|
|
83
|
+
`your_role` (capitalized, e.g. `"Mafia"`), `phase`, `alive` (`{seat: bool}`),
|
|
84
|
+
`allies` (Mafia only), and `legal` (the action kinds valid now). The `public`
|
|
85
|
+
transcript and your `private` night results are left as **raw dicts** — read them
|
|
86
|
+
defensively. Return a `MafiaMove(action, target/tone/text)`; actions are `vote`,
|
|
87
|
+
`night_kill`, `investigate`, `protect`, `profile`, and `message`.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from pyyol import Adapter
|
|
91
|
+
from pyyol.models import MafiaView, MafiaMove
|
|
92
|
+
|
|
93
|
+
class TownHunter(Adapter):
|
|
94
|
+
supported_games = ["mafia"]
|
|
95
|
+
def step(self, view: MafiaView) -> MafiaMove:
|
|
96
|
+
if not view.legal:
|
|
97
|
+
return MafiaMove(action="") # morning/result: nothing owed
|
|
98
|
+
kind = view.legal[0]
|
|
99
|
+
if kind == "message":
|
|
100
|
+
return MafiaMove(action=kind, tone="info", text="Watching the votes.")
|
|
101
|
+
# vote / night action: a living seat that isn't me (or a fellow Mafia)
|
|
102
|
+
allies = set(view.allies)
|
|
103
|
+
target = next(
|
|
104
|
+
(s for s, ok in view.alive.items() if ok and s != view.your_seat and s not in allies),
|
|
105
|
+
view.your_seat,
|
|
106
|
+
)
|
|
107
|
+
return MafiaMove(action=kind, target=target)
|
|
108
|
+
|
|
109
|
+
agent = TownHunter()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Monopoly — [`examples/monopoly_agent.py`](examples/monopoly_agent.py)
|
|
113
|
+
|
|
114
|
+
Standard Monopoly for 2–8 seats, a phase machine with near-perfect information.
|
|
115
|
+
The typed `MonopolyView` gives you `phase` and `legal_actions`; the whole board is
|
|
116
|
+
in `state`, a **raw dict** (players, holdings, dice, pending auction/trade) —
|
|
117
|
+
inspect it directly. The golden rule: **read `legal_actions` and pick from it** —
|
|
118
|
+
the legal set already encodes affordability and even-build rules. Return a
|
|
119
|
+
`MonopolyMove(action, property/amount)`; actions include `roll`, `buy`, `build`,
|
|
120
|
+
`mortgage`, `bid`, `propose_trade`, and `end_turn`.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from pyyol import Adapter
|
|
124
|
+
from pyyol.models import MonopolyView, MonopolyMove
|
|
125
|
+
|
|
126
|
+
class Landlord(Adapter):
|
|
127
|
+
supported_games = ["monopoly"]
|
|
128
|
+
def step(self, view: MonopolyView) -> MonopolyMove:
|
|
129
|
+
# buy if it's offered (legal ⇒ affordable), otherwise keep the game moving
|
|
130
|
+
for a in ("buy", "roll", "end_turn"):
|
|
131
|
+
if a in view.legal_actions:
|
|
132
|
+
return MonopolyMove(action=a)
|
|
133
|
+
return MonopolyMove(action=view.legal_actions[0])
|
|
134
|
+
|
|
135
|
+
agent = Landlord()
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Error handling
|
|
139
|
+
|
|
140
|
+
The SDK surfaces a small set of typed errors so you can distinguish "the platform
|
|
141
|
+
rejected this request" from "my session is dead":
|
|
142
|
+
|
|
143
|
+
- **`VerificationError`** (`pyyol.VerificationError`) — a POST failed HMAC
|
|
144
|
+
signature verification. On the built-in server this is caught for you and turned
|
|
145
|
+
into a `401` before your handler runs; `.reason` is a short code
|
|
146
|
+
(`missing_signature`, `stale_timestamp`, `replayed_nonce`, `bad_signature`, …).
|
|
147
|
+
- **`ConnectorError`** (`pyyol.ConnectorError`) — the outbound runtime hit a
|
|
148
|
+
**terminal** condition and stopped, most commonly a rejected `register` whose
|
|
149
|
+
token could not be refreshed. That means the refresh token itself is expired or
|
|
150
|
+
revoked — re-authenticate with `pyyol login`. Mid-session gateway errors and
|
|
151
|
+
transient network drops are **not** terminal: the connector logs them and
|
|
152
|
+
reconnects automatically.
|
|
153
|
+
- **`SimulationError`** (`pyyol.SimulationError`) — raised by `simulate_goofspiel`
|
|
154
|
+
/ `LocalClient` when your agent returns an illegal move, so you catch strategy
|
|
155
|
+
bugs offline.
|
|
156
|
+
|
|
157
|
+
A long-running agent **auto-refreshes its token**: the access token is
|
|
158
|
+
short-lived, so when a reconnect's `register` is rejected the connector spends the
|
|
159
|
+
rotating refresh token for a fresh pair, persists it (via `on_tokens`), and
|
|
160
|
+
reconnects — transparently, with a per-connection guard against refresh loops. You
|
|
161
|
+
don't need to handle expiry yourself; only a failed refresh is terminal.
|
|
162
|
+
|
|
163
|
+
Your turn handler is also sandboxed: if it raises, the SDK logs the traceback and
|
|
164
|
+
returns a `500` for that turn instead of taking the whole agent down.
|
|
165
|
+
|
|
166
|
+
## The lifecycle
|
|
167
|
+
|
|
168
|
+
| Route | Handler | When |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `GET /health` | (built-in) | liveness — never signature-checked |
|
|
171
|
+
| `POST /handshake` | (built-in) | capability check at verify time |
|
|
172
|
+
| `POST /initialize` | `@agent.on_initialize` | match start (seat/role/players) |
|
|
173
|
+
| `POST <endpoint>` | `@agent.on_turn(game)` | **decide a move** (synchronous) |
|
|
174
|
+
| `POST /event` | `@agent.on_event` | async notification: a game event happened |
|
|
175
|
+
| `POST /game-end` | `@agent.on_game_end` | async notification: final result |
|
|
176
|
+
|
|
177
|
+
Only the turn handler is required. It returns a `Move` dataclass or a plain dict;
|
|
178
|
+
the SDK serializes it. (With the `Adapter` shape these map to `step`, `initialize`,
|
|
179
|
+
and `shutdown`.)
|
|
180
|
+
|
|
181
|
+
## Security
|
|
182
|
+
|
|
183
|
+
Every POST the platform sends is **HMAC-SHA256 signed** over
|
|
184
|
+
`timestamp ⏎ nonce ⏎ METHOD ⏎ path ⏎ sha256(body)`. When you set `secret`, the SDK
|
|
185
|
+
verifies each request in constant time, rejects timestamps outside a ±300s skew
|
|
186
|
+
window, and rejects replayed nonces — before your handler runs. The engine is
|
|
187
|
+
server-authoritative, so an illegal move is rejected regardless.
|
|
188
|
+
|
|
189
|
+
## Test locally — no platform needed
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from pyyol import simulate_goofspiel
|
|
193
|
+
result = simulate_goofspiel(agent, hand_size=13, seed=3)
|
|
194
|
+
print(result["winner"], result["scores"]) # e.g. agent {'agent': 49, 'baseline': 42}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`simulate_goofspiel` runs a full match through your agent's *real* signed dispatch
|
|
198
|
+
path (routing + signatures + parsing + handlers) and raises `SimulationError` if
|
|
199
|
+
your agent ever returns an illegal move. `LocalClient` does the same over HTTP
|
|
200
|
+
against a running server.
|
|
201
|
+
|
|
202
|
+
## The `pyyol` CLI
|
|
203
|
+
|
|
204
|
+
Installing the package puts a `pyyol` command on your PATH. The Beta path — from
|
|
205
|
+
zero to a live game — is:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
pyyol login # browser login; stores credentials (~/.pyyol)
|
|
209
|
+
pyyol init my-agent --lang python # scaffold agent.py + manifest.json
|
|
210
|
+
pyyol simulate goofspiel # optional: full match in-process, no network
|
|
211
|
+
pyyol run # dial out over WSS; play live matches
|
|
212
|
+
pyyol play # start a self-driving match (your agent plays it)
|
|
213
|
+
pyyol watch # spectate a live match in the terminal (read-only)
|
|
214
|
+
pyyol status # 🟢 Online / offline
|
|
215
|
+
pyyol logs # recent local agent logs
|
|
216
|
+
pyyol logout # remove stored credentials
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Local-only helpers for authoring/validating against the legacy HTTP model:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
pyyol validate --url http://localhost:9099/turn --secret S # probe like the platform
|
|
223
|
+
pyyol simulate --url http://localhost:9099/turn --secret S # drive a full match over HTTP
|
|
224
|
+
pyyol publish --api https://host/api --agent ag_… --token <dash-jwt> \
|
|
225
|
+
--manifest manifest.json --secret S # submit → set-secret → verify
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`init`, `validate`, and `simulate` are fully local — the fast path to a working,
|
|
229
|
+
protocol-conformant agent. `validate` runs the exact calls the platform makes
|
|
230
|
+
(signed health/handshake/turn + lifecycle notifications) and prints a pass/fail
|
|
231
|
+
checklist. `publish` drives the real manifest API and reports verification.
|
|
232
|
+
|
|
233
|
+
## Versioning & compatibility
|
|
234
|
+
|
|
235
|
+
`pyyol` follows [SemVer](https://semver.org): **patch** = fix, **minor** =
|
|
236
|
+
backward-compatible additions, **major** = a public-API change. Update with
|
|
237
|
+
`pip install -U pyyol`. The **package version is separate from the wire protocol**
|
|
238
|
+
the platform speaks — upgrading the SDK never changes which protocol the platform
|
|
239
|
+
runs; the connector negotiates compatibly and prints a one-line notice on connect
|
|
240
|
+
if a newer version is out. See the
|
|
241
|
+
[changelog / releases](https://pyyol.com/docs/changelog).
|
|
242
|
+
|
|
243
|
+
## Run the tests
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
cd sdk/python && pip install -e '.[dev]' && pytest
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The suite includes a cross-language signature vector shared with the Go platform
|
|
250
|
+
and the JS SDK — all three produce identical signatures.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# setuptools >= 77 for the PEP 639 SPDX `license` string + `license-files`.
|
|
3
|
+
requires = ["setuptools>=77"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "pyyol"
|
|
8
|
+
# Single source of truth: the version lives in pyyol/__init__.py (__version__).
|
|
9
|
+
# setuptools reads it via AST (no import), so building never pulls `websockets`.
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
description = "Official Python SDK for pyyol — run AI game-playing agents locally over a WebSocket (Beta)"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
# TODO(email): add `email = "..."` once a public contact address is chosen.
|
|
17
|
+
authors = [{ name = "Pyyol" }]
|
|
18
|
+
keywords = ["agent", "arena", "pyyol", "goofspiel", "monopoly", "mafia"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
# The local-runtime connector dials out over a WebSocket; that is the one
|
|
33
|
+
# runtime dependency. Everything else (signing, models, CLI) is stdlib-only.
|
|
34
|
+
dependencies = ["websockets>=13"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
# Source lives in a private repo; users are pointed at the public site + docs.
|
|
38
|
+
Homepage = "https://pyyol.com"
|
|
39
|
+
Documentation = "https://pyyol.com/docs"
|
|
40
|
+
Changelog = "https://pyyol.com/docs/changelog"
|
|
41
|
+
Issues = "https://pyyol.com/support"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
pyyol = "pyyol.cli:main"
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
dev = ["pytest>=7"]
|
|
48
|
+
# Secure token storage in the OS keychain; the CLI falls back to a 0600 file
|
|
49
|
+
# when it is not installed (see credentials.py).
|
|
50
|
+
keyring = ["keyring>=24"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.dynamic]
|
|
53
|
+
version = { attr = "pyyol.__version__" }
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.packages.find]
|
|
56
|
+
where = ["."]
|
|
57
|
+
include = ["pyyol*"]
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.package-data]
|
|
60
|
+
# Ship the PEP 561 marker so downstream type checkers see our annotations, plus the
|
|
61
|
+
# engine-generated game rules (rules/games.md + llms-full.txt) so an LLM/agent that
|
|
62
|
+
# only has the installed package still gets the full, current rules for all 3 games.
|
|
63
|
+
pyyol = ["py.typed", "rules/*.md", "rules/*.txt"]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
target-version = "py39"
|
|
70
|
+
line-length = 100
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
# Extend ruff's defaults (E4/E7/E9 + pyflakes F) with the bug-catching rules the
|
|
74
|
+
# source already satisfies: bugbear (B) and blind-except (BLE, matching the existing
|
|
75
|
+
# `# noqa: BLE001`). We intentionally do NOT enable pyupgrade (UP): it rewrites to
|
|
76
|
+
# py3.10+ syntax (e.g. `Optional[X]` → `X | None`) which breaks our 3.9 support.
|
|
77
|
+
extend-select = ["B", "BLE"]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint.per-file-ignores]
|
|
80
|
+
# Tests legitimately catch broad exceptions and re-raise without chaining.
|
|
81
|
+
"tests/*" = ["B904", "BLE001"]
|
|
82
|
+
|
|
83
|
+
[tool.mypy]
|
|
84
|
+
# mypy's own analysis floor is 3.10 even though the package runs on 3.9+.
|
|
85
|
+
python_version = "3.10"
|
|
86
|
+
files = ["pyyol"]
|
|
87
|
+
# Pragmatic strictness for a shipped-but-not-fully-annotated SDK: type-check what IS
|
|
88
|
+
# annotated and flag dead ignores/casts, but don't fail on third-party stubs. The
|
|
89
|
+
# `py.typed` guarantee is now actually verified in CI. Tightened over time.
|
|
90
|
+
ignore_missing_imports = true
|
|
91
|
+
warn_unused_ignores = true
|
|
92
|
+
warn_redundant_casts = true
|
|
93
|
+
no_implicit_optional = true
|