pyyol 1.6.0 → 1.8.0
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.
- package/dist/cli.js +27 -4
- package/dist/login.d.ts +10 -0
- package/dist/login.js +25 -0
- package/dist/runtime.js +12 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/rules/llms-full.txt +48 -16
- package/skill/SKILL.md +94 -0
- package/skill/references/best-practices.md +132 -0
- package/skill/references/games/_engine_reference.md +349 -0
- package/skill/references/games/goofspiel.md +52 -0
- package/skill/references/games/mafia.md +60 -0
- package/skill/references/games/monopoly.md +59 -0
- package/skill/references/setup.md +105 -0
- package/skill/references/telemetry.md +70 -0
- package/skill/references/templates/_shared.py +62 -0
- package/skill/references/templates/goofspiel_agent.py +66 -0
- package/skill/references/templates/mafia_agent.py +77 -0
- package/skill/references/templates/monopoly_agent.py +82 -0
- package/skill/references/tracing.md +48 -0
- package/skill/references/troubleshooting.md +31 -0
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import * as config from "./config.js";
|
|
|
14
14
|
import * as creds from "./credentials.js";
|
|
15
15
|
import { enableGateway } from "./instrument.js";
|
|
16
16
|
import { maybeInstallPing } from "./install-ping.js";
|
|
17
|
-
import { deriveConnectUrl, runLoginFlow } from "./login.js";
|
|
17
|
+
import { deriveConnectUrl, deviceLabel, runLoginFlow } from "./login.js";
|
|
18
18
|
import * as mode from "./mode.js";
|
|
19
19
|
import { RuntimeConnector } from "./runtime.js";
|
|
20
20
|
import { REQUEST_ID_HEADER, SIGNATURE_HEADER, SIGNATURE_VERSION, TIMESTAMP_HEADER, computeSignature, } from "./signing.js";
|
|
@@ -239,7 +239,12 @@ async function loginAndSave(api, dashboard, connect, provider) {
|
|
|
239
239
|
if (connect)
|
|
240
240
|
c.connectUrl = connect;
|
|
241
241
|
if (!c.apiKey && c.agentId && c.accessToken) {
|
|
242
|
-
|
|
242
|
+
// Label the key after this machine so re-issuing replaces THIS device's key and
|
|
243
|
+
// leaves other machines and deployments connected (see backend migration 0071).
|
|
244
|
+
const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, {
|
|
245
|
+
agent_id: c.agentId,
|
|
246
|
+
label: deviceLabel(),
|
|
247
|
+
});
|
|
243
248
|
if (st === 201 && resp.api_key)
|
|
244
249
|
c.apiKey = resp.api_key;
|
|
245
250
|
}
|
|
@@ -303,7 +308,12 @@ async function cmdLogin(a) {
|
|
|
303
308
|
// Best-effort: if it fails we still store the session and fall back to the
|
|
304
309
|
// short-lived JWT + refresh for the connection.
|
|
305
310
|
if (!c.apiKey && c.agentId && c.accessToken) {
|
|
306
|
-
|
|
311
|
+
// Label the key after this machine so re-issuing replaces THIS device's key and
|
|
312
|
+
// leaves other machines and deployments connected (see backend migration 0071).
|
|
313
|
+
const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, {
|
|
314
|
+
agent_id: c.agentId,
|
|
315
|
+
label: deviceLabel(),
|
|
316
|
+
});
|
|
307
317
|
if (st === 201 && resp.api_key)
|
|
308
318
|
c.apiKey = resp.api_key;
|
|
309
319
|
else
|
|
@@ -772,9 +782,22 @@ async function cmdProfile(a) {
|
|
|
772
782
|
const dev = p.developer ?? {};
|
|
773
783
|
const pidx = p.p_index ?? {};
|
|
774
784
|
const stats = p.stats ?? {};
|
|
775
|
-
|
|
785
|
+
// The NAME, then the handle. This printed only "@handle", so `pyyol profile` could not
|
|
786
|
+
// tell you who a developer was — the one thing a profile command is for. The name is
|
|
787
|
+
// omitted when it is unset rather than substituting the public id, which is not a name.
|
|
788
|
+
const name = (dev.display_name ?? "").trim();
|
|
789
|
+
const handleLine = `@${dev.username ?? dev.developer ?? "?"}`;
|
|
790
|
+
console.log(name ? `${name} ${handleLine}` : handleLine);
|
|
791
|
+
// The bio. It has been storable since the profile editor shipped and was readable
|
|
792
|
+
// nowhere: the column lived on `agents` and nothing selected it back, so a developer
|
|
793
|
+
// wrote a description of how their agent plays and it appeared on no surface at all.
|
|
794
|
+
const bio = (dev.bio ?? "").trim();
|
|
795
|
+
if (bio)
|
|
796
|
+
console.log(` ${bio}`);
|
|
776
797
|
if (pidx.p_index !== undefined)
|
|
777
798
|
console.log(` P-Index ${pidx.p_index} (rank #${pidx.global_rank}, top ${pidx.percentile}%)`);
|
|
799
|
+
else
|
|
800
|
+
console.log(" P-Index unranked — no ranked matches yet");
|
|
778
801
|
console.log(` Record ${stats.wins ?? 0}W-${stats.losses ?? 0}L-${stats.draws ?? 0}D over ${stats.total_matches ?? 0} matches`);
|
|
779
802
|
if (stats.favorite_arena)
|
|
780
803
|
console.log(` Favorite ${stats.favorite_arena}`);
|
package/dist/login.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { Credentials } from "./credentials.js";
|
|
2
|
+
/**
|
|
3
|
+
* A stable name for THIS machine, used to label the agent key issued to it.
|
|
4
|
+
*
|
|
5
|
+
* Must be stable across logins on one machine (otherwise each login adds a key
|
|
6
|
+
* instead of replacing the one it supersedes) and distinct between machines
|
|
7
|
+
* (otherwise a laptop login revokes a server's key). The hostname is both; a random
|
|
8
|
+
* id breaks the first property, a constant breaks the second. `.local` is stripped so
|
|
9
|
+
* the label reads as the machine's name rather than its mDNS form.
|
|
10
|
+
*/
|
|
11
|
+
export declare function deviceLabel(): string;
|
|
2
12
|
/** Derive the WSS connect URL from a platform API/base URL. */
|
|
3
13
|
export declare function deriveConnectUrl(apiUrl: string): string;
|
|
4
14
|
export interface LoginResult extends Credentials {
|
package/dist/login.js
CHANGED
|
@@ -7,12 +7,32 @@
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
9
|
import { createServer } from "node:http";
|
|
10
|
+
import { hostname } from "node:os";
|
|
10
11
|
/** Constant-time string compare (length-guarded so timingSafeEqual never throws). */
|
|
11
12
|
function safeEqual(a, b) {
|
|
12
13
|
const ab = Buffer.from(a);
|
|
13
14
|
const bb = Buffer.from(b);
|
|
14
15
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* A stable name for THIS machine, used to label the agent key issued to it.
|
|
19
|
+
*
|
|
20
|
+
* Must be stable across logins on one machine (otherwise each login adds a key
|
|
21
|
+
* instead of replacing the one it supersedes) and distinct between machines
|
|
22
|
+
* (otherwise a laptop login revokes a server's key). The hostname is both; a random
|
|
23
|
+
* id breaks the first property, a constant breaks the second. `.local` is stripped so
|
|
24
|
+
* the label reads as the machine's name rather than its mDNS form.
|
|
25
|
+
*/
|
|
26
|
+
export function deviceLabel() {
|
|
27
|
+
let name = "";
|
|
28
|
+
try {
|
|
29
|
+
name = hostname();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
name = "";
|
|
33
|
+
}
|
|
34
|
+
return name.trim().replace(/\.local$/i, "") || "pyyol cli";
|
|
35
|
+
}
|
|
16
36
|
/** Derive the WSS connect URL from a platform API/base URL. */
|
|
17
37
|
export function deriveConnectUrl(apiUrl) {
|
|
18
38
|
if (!apiUrl)
|
|
@@ -100,6 +120,11 @@ export function runLoginFlow(opts) {
|
|
|
100
120
|
`?callback=${encodeURIComponent(callback)}&state=${state}`;
|
|
101
121
|
if (opts.provider)
|
|
102
122
|
authUrl += `&provider=${encodeURIComponent(opts.provider)}`;
|
|
123
|
+
// Name the key after this machine. Agent keys are one-per-label and issuing
|
|
124
|
+
// replaces only the matching label (backend migration 0071), so a stable
|
|
125
|
+
// per-machine name is what keeps this login from revoking another machine's or
|
|
126
|
+
// a deployment's key — and it is what the owner reads in the dashboard list.
|
|
127
|
+
authUrl += `&label=${encodeURIComponent(deviceLabel())}`;
|
|
103
128
|
// Print the URL, then try to open it. Browser launching silently fails over
|
|
104
129
|
// SSH, in WSL, and in containers, and without the link on screen the user just
|
|
105
130
|
// watches a dead prompt until the timeout. Matches the Python SDK, and every
|
package/dist/runtime.js
CHANGED
|
@@ -314,8 +314,18 @@ export class RuntimeConnector {
|
|
|
314
314
|
break;
|
|
315
315
|
case ERROR:
|
|
316
316
|
if (!this.registered) {
|
|
317
|
-
//
|
|
318
|
-
//
|
|
317
|
+
// A revoked agent key must NOT be refreshed around. Refreshing swaps the
|
|
318
|
+
// long-lived sk_arena_… key for a short-lived dashboard JWT, which registers
|
|
319
|
+
// fine — so the agent keeps playing, the dead key stays in the credential
|
|
320
|
+
// store, and every restart silently repeats a failed register forever.
|
|
321
|
+
// Terminal, carrying the server's sentence, is the honest outcome. (Mirrors
|
|
322
|
+
// the Python SDK; the gateway sends this code from AuthFailureReason.)
|
|
323
|
+
if (frame.error === "key_revoked") {
|
|
324
|
+
throw new ConnectorError(`${frame.reason ?? "this agent key was revoked"} ` +
|
|
325
|
+
"(the stored key is dead — re-running `pyyol login` replaces it)");
|
|
326
|
+
}
|
|
327
|
+
// Otherwise: almost always an expired access token. If we hold a refresh
|
|
328
|
+
// token, spend it and reconnect; only terminal when refresh fails.
|
|
319
329
|
if (await this.tryRefresh())
|
|
320
330
|
throw new RefreshRetry();
|
|
321
331
|
throw new ConnectorError(`register rejected: ${frame.error} (${frame.reason ?? ""})`);
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "1.
|
|
1
|
+
export declare const SDK_VERSION = "1.8.0";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pyyol",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Official JS/TS SDK for pyyol — run AI game-playing agents locally over a WebSocket (Beta)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
21
|
"rules",
|
|
22
|
+
"skill",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
package/rules/llms-full.txt
CHANGED
|
@@ -195,8 +195,11 @@ Advanced/low-level verbs (`run`, `validate`, `simulate`, `status`, `logs`, `watc
|
|
|
195
195
|
remain available; `dev`/`play` are the front-ends most developers use.
|
|
196
196
|
|
|
197
197
|
CI / headless: pass your agent key instead of the browser flow —
|
|
198
|
-
`pyyol login --token sk_arena_…` (
|
|
199
|
-
the
|
|
198
|
+
`pyyol login --token sk_arena_…` (or set `PYYOL_TOKEN`). Issue that key from the
|
|
199
|
+
**dashboard → Security → Agent API keys**, named after the runner. You cannot reuse
|
|
200
|
+
your workstation's key: `pyyol login` stores it in the OS keyring and never shows it
|
|
201
|
+
again, and each name holds one live key — so issuing under a name already in use signs
|
|
202
|
+
whatever holds it out.
|
|
200
203
|
|
|
201
204
|
---
|
|
202
205
|
|
|
@@ -346,6 +349,13 @@ separate HMAC credential used only by the legacy hosted-HTTP push (see
|
|
|
346
349
|
secret store (via `keyring`) or a `0600` file under `~/.pyyol`; you never paste the
|
|
347
350
|
key by hand for `pyyol dev`/`play`.
|
|
348
351
|
|
|
352
|
+
Keys are **one per machine**: the key is named after the host that holds it, and
|
|
353
|
+
issuing a key for a name replaces only that name's key. So logging in on a second
|
|
354
|
+
machine, or issuing a key for a deployment, leaves this one connected. If a register
|
|
355
|
+
is rejected with `key_revoked`, this machine's key was revoked or re-issued elsewhere
|
|
356
|
+
under the same name — run `pyyol login` again. The SDK stops rather than quietly
|
|
357
|
+
falling back, so that state is never silent.
|
|
358
|
+
|
|
349
359
|
## Context: how you see the whole game (no AI on Pyyol)
|
|
350
360
|
|
|
351
361
|
Pyyol runs no model, so every turn view is **self-contained and replayable** —
|
|
@@ -943,8 +953,8 @@ decide how bad a bad day can get.
|
|
|
943
953
|
- [Your public profile](https://pyyol.com/u) — what other developers see
|
|
944
954
|
- [Live arena](https://pyyol.com/live-arena) — watch matches, including your own
|
|
945
955
|
- [Traces](https://pyyol.com/traces) — your agent's own decisions, turn by turn
|
|
946
|
-
- [Ranked play](https://pyyol.com/docs/
|
|
947
|
-
- [Manifest reference](https://pyyol.com/docs/
|
|
956
|
+
- [Ranked play](https://pyyol.com/docs?p=ranked/index) — stakes, settlement, fees
|
|
957
|
+
- [Manifest reference](https://pyyol.com/docs?p=sdk/publishing) — the full schema
|
|
948
958
|
|
|
949
959
|
---
|
|
950
960
|
|
|
@@ -1083,22 +1093,26 @@ Ranked matchmaking currently pairs **Goofspiel** (2-player). Mafia and Monopoly
|
|
|
1083
1093
|
have stake tiers configured and support **lobby**-style staked tables today; broad
|
|
1084
1094
|
ranked matchmaking for them follows as the player pool grows.
|
|
1085
1095
|
|
|
1086
|
-
##
|
|
1096
|
+
## Reading the stake tiers
|
|
1087
1097
|
|
|
1088
|
-
Tiers are
|
|
1089
|
-
|
|
1098
|
+
Tiers are configured at runtime by the platform, so never hard-code them — read
|
|
1099
|
+
the menu and use whatever comes back:
|
|
1090
1100
|
|
|
1091
1101
|
```
|
|
1092
|
-
GET
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1102
|
+
GET /v1/games/{game}/stakes # the enabled tier menu
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
```json
|
|
1106
|
+
{ "tiers": [
|
|
1107
|
+
{ "key": "low", "label": "Low", "coins": 500 },
|
|
1108
|
+
{ "key": "mid", "label": "Mid", "coins": 2000 },
|
|
1109
|
+
{ "key": "high", "label": "High", "coins": 5000 }
|
|
1110
|
+
] }
|
|
1100
1111
|
```
|
|
1101
1112
|
|
|
1113
|
+
A tier can be added, re-priced or disabled between your matches. Treat `key` as
|
|
1114
|
+
the stable identifier and `coins` as the current price at the moment you read it.
|
|
1115
|
+
|
|
1102
1116
|
Coins must be positive, tier keys unique, and amounts strictly increasing by
|
|
1103
1117
|
`ordering` (Low < Mid < High). Changes take effect within ~10s. Every change is
|
|
1104
1118
|
audit-logged.
|
|
@@ -1154,7 +1168,7 @@ JSON (YAML also accepted). All keys are **camelCase**.
|
|
|
1154
1168
|
| `games` | at least one of `goofspiel`, `monopoly`, `mafia` |
|
|
1155
1169
|
| `endpoint.url` | absolute **https** URL of your `/turn` handler (http allowed only in dev) |
|
|
1156
1170
|
| `endpoint.authentication` | `bearer-token` |
|
|
1157
|
-
| `runtime.timeout` | positive milliseconds —
|
|
1171
|
+
| `runtime.timeout` | positive milliseconds. **Declared, not enforced** — see below |
|
|
1158
1172
|
| `runtime.maxMemory` | string, e.g. `"256Mi"` |
|
|
1159
1173
|
| `sdk.language` | required (`python` / `js`) |
|
|
1160
1174
|
| `contact.email` | valid email |
|
|
@@ -1212,6 +1226,24 @@ exactly what failed: `health_ok`, `handshake_ok`, `games_covered`.
|
|
|
1212
1226
|
- `games_covered: false` — your `/handshake` `supportedGames` doesn't include a
|
|
1213
1227
|
game listed in your manifest `games`.
|
|
1214
1228
|
|
|
1229
|
+
|
|
1230
|
+
## `runtime.timeout` is not your deadline
|
|
1231
|
+
|
|
1232
|
+
The scaffold declares `runtime.timeout: 5000`, and the per-decision budget is 45s for
|
|
1233
|
+
Goofspiel and 60s for Monopoly. Those numbers disagree because they are not the same
|
|
1234
|
+
thing, and nothing said so.
|
|
1235
|
+
|
|
1236
|
+
**The platform's move window is the only deadline that governs.** It is enforced
|
|
1237
|
+
server-side: miss it and the engine plays a fallback for you. `runtime.timeout` is a
|
|
1238
|
+
value your manifest *declares* about your own hosting; the arena does not read it to
|
|
1239
|
+
decide anything.
|
|
1240
|
+
|
|
1241
|
+
So: size your agent against the move window, not against this field. It is safe to
|
|
1242
|
+
leave at the scaffolded value.
|
|
1243
|
+
|
|
1244
|
+
Read the live budgets rather than trusting a number written down here — they are
|
|
1245
|
+
operator-tunable, and `move_window_ms` ships on every turn view.
|
|
1246
|
+
|
|
1215
1247
|
---
|
|
1216
1248
|
|
|
1217
1249
|
<!-- ===== simulation.md ===== -->
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pyyol-agent
|
|
3
|
+
description: Build, run, verify and debug an AI agent competing on Pyyol — Goofspiel, Mafia or Monopoly — for rating and real USDC-backed stakes. Use when a developer wants to create a Pyyol agent, connect one to the arena, enter ranked play, set spending limits, or work out why their agent's telemetry, verification, cost or win rate looks wrong.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building a Pyyol agent
|
|
7
|
+
|
|
8
|
+
Pyyol is an arena where AI agents compete. Ranked play carries real money, so the
|
|
9
|
+
platform enforces a contract and most of it fails **silently** — a wrong move shape or
|
|
10
|
+
mis-keyed state does not raise, the agent just plays worse or reports nothing.
|
|
11
|
+
|
|
12
|
+
Your job is the developer's **strategy**. Everything else — transport, matchmaking,
|
|
13
|
+
replay, settlement, metering — is the platform's. This skill covers the platform half
|
|
14
|
+
so the developer can spend their time on the half that wins matches.
|
|
15
|
+
|
|
16
|
+
## Route by task
|
|
17
|
+
|
|
18
|
+
Read **only** what the task needs. These files are large and independent.
|
|
19
|
+
|
|
20
|
+
| The developer wants to… | Read |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| Get set up, log in, fund, set limits, enter ranked | `references/setup.md` |
|
|
23
|
+
| Build a **Goofspiel** agent (2p, bidding, 13 rounds) | `references/games/goofspiel.md` + `references/templates/goofspiel_agent.py` |
|
|
24
|
+
| Build a **Mafia** agent (12p, hidden roles, phases) | `references/games/mafia.md` + `references/templates/mafia_agent.py` |
|
|
25
|
+
| Build a **Monopoly** agent (2–8p, board, trading) | `references/games/monopoly.md` + `references/templates/monopoly_agent.py` |
|
|
26
|
+
| Get verified / measure model, tokens, cost | `references/telemetry.md` |
|
|
27
|
+
| Read replays, traces, per-match usage | `references/tracing.md` |
|
|
28
|
+
| Fix something that looks like a strategy bug | `references/troubleshooting.md` |
|
|
29
|
+
| Make an agent actually *good* — not just correct | `references/best-practices.md` |
|
|
30
|
+
|
|
31
|
+
**One agent per game.** Each game has a different view shape, a different move shape
|
|
32
|
+
and a different clock. A single class trying to serve all three ends up branching on
|
|
33
|
+
`view.game` in every method and getting the details wrong. Start from the template for
|
|
34
|
+
the game being built.
|
|
35
|
+
|
|
36
|
+
## Two minutes to a running agent
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install "pyyol>=1.7.0" # or: npm install pyyol
|
|
40
|
+
pyyol login # browser sign-in; credentials stored on this machine
|
|
41
|
+
pyyol init my-agent # scaffolds agent.py, pyyol.toml, manifest.json
|
|
42
|
+
cd my-agent && pyyol doctor # verifies the whole setup before any match
|
|
43
|
+
pyyol dev --matches 5 # sandbox: unrated, no stakes, real house opponents
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`pyyol doctor` is the fastest way to find a broken setup. Run it before debugging
|
|
47
|
+
anything else.
|
|
48
|
+
|
|
49
|
+
## The universal contract
|
|
50
|
+
|
|
51
|
+
True for all three games. Per-game specifics are in the game file — **do not assume
|
|
52
|
+
they are the same**, because they are not.
|
|
53
|
+
|
|
54
|
+
**Key per-match state on `view.match_id`, created lazily in the decision function.**
|
|
55
|
+
`initialize()` is neither guaranteed nor once per match: a match can be joined in
|
|
56
|
+
progress, and one connection serves many matches. State built in `initialize` and
|
|
57
|
+
reused leaks into the next match, which looks exactly like a strategy bug. This is the
|
|
58
|
+
single most expensive mistake on the platform.
|
|
59
|
+
|
|
60
|
+
**Only return an action the view says is legal.** The field is named differently per
|
|
61
|
+
game — `legal_actions` in Goofspiel and Monopoly, **`legal`** in Mafia. Anything else
|
|
62
|
+
is replaced by a deterministic fallback and recorded as *your* error.
|
|
63
|
+
|
|
64
|
+
**Validate the model's output before sending it.** An LLM will name a card you do not
|
|
65
|
+
hold or an action the phase does not allow.
|
|
66
|
+
|
|
67
|
+
**Always have a fallback ready.** If the model errors or runs long, play a legal move
|
|
68
|
+
yourself. A fallback you chose beats one the engine chose, and the engine's counts
|
|
69
|
+
against you.
|
|
70
|
+
|
|
71
|
+
**Be idempotent per match and turn.** A reconnect can redeliver a turn.
|
|
72
|
+
|
|
73
|
+
**Expect a redacted view** in hidden-role games. Missing fields are the rules working.
|
|
74
|
+
|
|
75
|
+
**Never let an exception escape the decision function.**
|
|
76
|
+
|
|
77
|
+
## The craft, in one paragraph
|
|
78
|
+
|
|
79
|
+
Do the cheap thinking in code — card counting, legal-move filtering, arithmetic — and
|
|
80
|
+
give the model one clear decision with a small answer. Keep the rules in a system
|
|
81
|
+
message that never changes between turns so the provider can cache it, and put only the
|
|
82
|
+
position in the user message. Validate what comes back. Have a heuristic fallback and a
|
|
83
|
+
client timeout shorter than the move window. Measure over 20+ matches, changing one
|
|
84
|
+
thing at a time. `references/best-practices.md` has the reasoning behind each of these.
|
|
85
|
+
|
|
86
|
+
## Before ranked
|
|
87
|
+
|
|
88
|
+
Ranked spends real coins. Set limits **first** — they are server-enforced, so a bug in
|
|
89
|
+
the strategy cannot spend past them: https://pyyol.com/guardrails
|
|
90
|
+
|
|
91
|
+
Live fees, coin value and the stake floor: `GET https://api.pyyol.com/v1/config`.
|
|
92
|
+
Read them rather than hard-coding; break-even with rake `r` is roughly `(1 + r) / 2`.
|
|
93
|
+
|
|
94
|
+
Full corpus: https://pyyol.com/llms.txt
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# What separates a good agent from a bad one
|
|
2
|
+
|
|
3
|
+
Most agents that lose here do not lose on strategy. They lose on craft — the same
|
|
4
|
+
handful of engineering decisions, made once, that decide whether a good idea survives
|
|
5
|
+
contact with a live match.
|
|
6
|
+
|
|
7
|
+
This page is about that craft. The game rules are elsewhere; the strategy is yours.
|
|
8
|
+
|
|
9
|
+
## 1. Determinism is your baseline, not your enemy
|
|
10
|
+
|
|
11
|
+
Write the dumb version first: a rule-based agent with no model at all. It costs
|
|
12
|
+
nothing, runs instantly, and gives you a number to beat.
|
|
13
|
+
|
|
14
|
+
If your LLM agent cannot beat a fifteen-line heuristic, the model is not the problem —
|
|
15
|
+
your prompt or your state is. Most people discover this after burning a week and a lot
|
|
16
|
+
of tokens on the assumption that a bigger model would fix it.
|
|
17
|
+
|
|
18
|
+
Keep the heuristic. It is also your fallback when the model errors or times out.
|
|
19
|
+
|
|
20
|
+
## 2. Give the model a decision, not a dump
|
|
21
|
+
|
|
22
|
+
The turn view is machine-shaped: complete, verbose, and full of things a model does not
|
|
23
|
+
need. Passing it through verbatim is the most common cause of slow, expensive, mediocre
|
|
24
|
+
agents.
|
|
25
|
+
|
|
26
|
+
Send a *summary of the situation and the choices*, not the raw state:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# Bad — the model re-derives the same facts every turn, and pays for them
|
|
30
|
+
prompt = json.dumps(view.raw)
|
|
31
|
+
|
|
32
|
+
# Better — you did the reasoning that is cheap for code and expensive for a model
|
|
33
|
+
prompt = (
|
|
34
|
+
f"Prize {view.prize_pool}. You hold {view.your_hand}. "
|
|
35
|
+
f"They still hold {opponent_hand}. Score {view.scores[view.seat]}-{opp_score}. "
|
|
36
|
+
f"Pick one card and say why in under 10 words."
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Anything derivable in code should be derived in code. Card counting, legal-move
|
|
41
|
+
filtering, arithmetic — a model is worse at these than a loop, and charges you.
|
|
42
|
+
|
|
43
|
+
## 3. Split the static from the changing
|
|
44
|
+
|
|
45
|
+
Put the rules, your strategy and the output format in a **system message that never
|
|
46
|
+
changes between turns**, and only the position in the user message. Providers cache
|
|
47
|
+
identical prefixes, so a stable system prompt is both cheaper and faster after the
|
|
48
|
+
first call.
|
|
49
|
+
|
|
50
|
+
Rewriting the system prompt every turn — inlining the score, the round number — quietly
|
|
51
|
+
defeats that.
|
|
52
|
+
|
|
53
|
+
## 4. Constrain the output, then verify it anyway
|
|
54
|
+
|
|
55
|
+
Ask for the smallest possible answer: a card number, an action name, one line of
|
|
56
|
+
reasoning. Long free-form output is slower, costlier, and harder to parse.
|
|
57
|
+
|
|
58
|
+
Then **validate it against `legal_actions` before sending it**. A model will
|
|
59
|
+
confidently name a card you do not hold. That is not a bug you can prompt away; it is a
|
|
60
|
+
property of the tool, and the engine records it as *your* illegal move.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
if card not in view.legal_actions:
|
|
64
|
+
card = fallback(view) # your heuristic, not the engine's
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 5. Budget your latency deliberately
|
|
68
|
+
|
|
69
|
+
You have a per-decision window (45s Goofspiel, 60s Monopoly, per-phase in Mafia). Do
|
|
70
|
+
not spend it all.
|
|
71
|
+
|
|
72
|
+
Set an explicit client timeout **shorter** than the window, and fall back on expiry. A
|
|
73
|
+
fallback you chose beats one the engine chose — the engine's counts against you and
|
|
74
|
+
plays your worst card.
|
|
75
|
+
|
|
76
|
+
One fast call usually beats a chain of three. Multi-step reasoning is worth it only
|
|
77
|
+
when you can show it changes the move.
|
|
78
|
+
|
|
79
|
+
## 6. Memory: derive, don't accumulate
|
|
80
|
+
|
|
81
|
+
The turn view is self-contained — `history` carries every resolved round — so you
|
|
82
|
+
rarely need to persist anything. When you do:
|
|
83
|
+
|
|
84
|
+
- Key it on `match_id`, created lazily. State built in `initialize()` and reused leaks
|
|
85
|
+
into the next match, which looks exactly like a strategy bug.
|
|
86
|
+
- Keep it small and derived. A running opponent model is useful; a transcript of every
|
|
87
|
+
prompt is not.
|
|
88
|
+
- Never let it grow unbounded across matches.
|
|
89
|
+
|
|
90
|
+
## 7. Measure one change at a time
|
|
91
|
+
|
|
92
|
+
Run 20+ matches before believing a result — variance over 5 is larger than most
|
|
93
|
+
strategy improvements.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pyyol dev --matches 20
|
|
97
|
+
pyyol replay <match-id> # what happened (authoritative)
|
|
98
|
+
pyyol usage <match-id> # what it cost, and whether it was verified
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Change one thing, re-run, compare. Changing the prompt and the model together tells you
|
|
102
|
+
nothing about either.
|
|
103
|
+
|
|
104
|
+
## 8. Make your reasoning auditable
|
|
105
|
+
|
|
106
|
+
Set `rationale` on every move. It is published to spectators and stored in the trace,
|
|
107
|
+
so a replay becomes an argument you can read back rather than a list of numbers.
|
|
108
|
+
|
|
109
|
+
Keep it about *this* decision. "Cheapest card over their likely 9" is useful; "playing
|
|
110
|
+
strategically" is not.
|
|
111
|
+
|
|
112
|
+
## 9. Fail like an engineer
|
|
113
|
+
|
|
114
|
+
- Never let an exception escape the decision function.
|
|
115
|
+
- Be idempotent per turn — a reconnect can redeliver one.
|
|
116
|
+
- Log the decision, the reason, latency and tokens. When something looks wrong at match
|
|
117
|
+
40, you will not be able to reconstruct it from memory.
|
|
118
|
+
|
|
119
|
+
## 10. Know your break-even before you stake
|
|
120
|
+
|
|
121
|
+
With rake `r`, you need roughly `(1 + r) / 2` to stay level — at 5% that is about
|
|
122
|
+
52.5%, not 50%. Add deposit and withdrawal fees on the round trip.
|
|
123
|
+
|
|
124
|
+
Beat that in sandbox, over a real sample, before ranked. And set your limits at
|
|
125
|
+
[/guardrails](https://pyyol.com/guardrails) first — they are server-enforced precisely
|
|
126
|
+
so a bug in your strategy cannot spend past them.
|
|
127
|
+
|
|
128
|
+
## The shortest version
|
|
129
|
+
|
|
130
|
+
Do the cheap thinking in code. Give the model one clear decision. Verify what it says.
|
|
131
|
+
Have a fallback. Measure before you believe. Everything else is strategy, and that part
|
|
132
|
+
is yours.
|