pokertools-arena 0.4.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/.env.example +8 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/bin/pokertools-arena.mjs +197 -0
- package/dist/.nojekyll +0 -0
- package/dist/app.js +118427 -0
- package/dist/arena-env.js +2 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.html +382 -0
- package/dist/og-image.png +0 -0
- package/dist/og-image.svg +190 -0
- package/dist/pokertools-arena.html +121106 -0
- package/dist/styles.css +2289 -0
- package/docs/architecture/CONTEXT.md +202 -0
- package/docs/diagnostics/SUMMARY.md +102 -0
- package/docs/diagnostics/summary.json +337 -0
- package/docs/legal/THIRD_PARTY_NOTICES.md +15 -0
- package/docs/releases/RELEASE_NOTES.md +171 -0
- package/docs/verification/0.3.x.md +250 -0
- package/package.json +77 -0
- package/src/app.js +2458 -0
- package/src/assets/favicon.svg +4 -0
- package/src/assets/og-image.png +0 -0
- package/src/assets/og-image.svg +190 -0
- package/src/benchmark/scenarios.js +108 -0
- package/src/env/arena-env.js +2 -0
- package/src/index.html +382 -0
- package/src/lib/decision-core.js +1288 -0
- package/src/shims/crypto.cjs +19 -0
- package/src/styles.css +2289 -0
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
// Pure decision core shared by the browser arena and the Node diagnostic
|
|
2
|
+
// harness. This module intentionally has no DOM dependency so regression tests
|
|
3
|
+
// and the real-API diagnostic script can exercise the exact production
|
|
4
|
+
// action-space construction, state serialization and request formats.
|
|
5
|
+
import { getCardCodes, rank, rankDescription, HAND_RANK_DESCRIPTIONS } from '@pokertools/evaluator';
|
|
6
|
+
|
|
7
|
+
export const ACTION = Object.freeze({
|
|
8
|
+
FOLD: 'FOLD', CHECK: 'CHECK', CALL: 'CALL', BET: 'BET', RAISE: 'RAISE',
|
|
9
|
+
SHOW: 'SHOW', MUCK: 'MUCK', TIME_BANK: 'TIME_BANK',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const DECISION_CONTEXT_VERSION = 3;
|
|
13
|
+
export const RECENT_PUBLIC_HANDS = 8;
|
|
14
|
+
export const DECISION_OBJECTIVE = 'Choose exactly one legal action that best maximizes tournament chip EV from the supplied state. Use only the information in this state and only an actionId present in legalActions.';
|
|
15
|
+
export const INFORMATION_POLICY = Object.freeze({
|
|
16
|
+
private: 'hero hole cards only',
|
|
17
|
+
public: 'board, pot, blinds, stacks, positions, current-hand actions, recent public hand history, public player statistics, and legal actions',
|
|
18
|
+
excluded: 'opponent hole cards, other agents reasoning, model outputs, API/provider metadata, hidden deck state, and future cards',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function asNumber(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; }
|
|
22
|
+
export function round(n, digits = 2) { const f = 10 ** digits; return Math.round(asNumber(n) * f) / f; }
|
|
23
|
+
export function clamp(n, min, max) { return Math.min(max, Math.max(min, asNumber(n, min))); }
|
|
24
|
+
|
|
25
|
+
export function summarizeError(err) {
|
|
26
|
+
if (!err) return 'Unknown error';
|
|
27
|
+
if (err.name === 'AbortError') return 'Request aborted or timed out';
|
|
28
|
+
return String(err.message || err).slice(0, 500);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ArenaRequestError extends Error {
|
|
32
|
+
constructor(message, { status = null, category = 'provider', payload = null, incidents = [] } = {}) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'ArenaRequestError';
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.category = category;
|
|
37
|
+
this.payload = payload;
|
|
38
|
+
this.incidents = incidents;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function requestErrorCategory(status) {
|
|
42
|
+
if (Number(status) === 429) return 'rate_limit';
|
|
43
|
+
return 'provider';
|
|
44
|
+
}
|
|
45
|
+
export function decisionErrorCategory(err) {
|
|
46
|
+
if (err?.name === 'AbortError') return 'timeout';
|
|
47
|
+
if (err?.category) return err.category;
|
|
48
|
+
return 'model';
|
|
49
|
+
}
|
|
50
|
+
export function retryDelayMs(response) {
|
|
51
|
+
const raw = response?.headers?.get?.('retry-after');
|
|
52
|
+
const seconds = Number(raw);
|
|
53
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(1000, Math.max(100, seconds * 1000));
|
|
54
|
+
return 250;
|
|
55
|
+
}
|
|
56
|
+
export function sleepWithSignal(ms, signal) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
if (signal?.aborted) return reject(new DOMException('Aborted', 'AbortError'));
|
|
59
|
+
const timer = setTimeout(done, Math.max(0, ms));
|
|
60
|
+
const abort = () => { clearTimeout(timer); cleanup(); reject(new DOMException('Aborted', 'AbortError')); };
|
|
61
|
+
function cleanup() { signal?.removeEventListener?.('abort', abort); }
|
|
62
|
+
function done() { cleanup(); resolve(); }
|
|
63
|
+
signal?.addEventListener?.('abort', abort, { once: true });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export async function fetchJsonWithRetry(url, options, { maxRetries = 1 } = {}) {
|
|
67
|
+
const incidents = [];
|
|
68
|
+
for (let attempt = 0; ; attempt++) {
|
|
69
|
+
if (attempt > 0) diagnosticsCounters.retries++;
|
|
70
|
+
diagnosticsCounters.requests++;
|
|
71
|
+
let response;
|
|
72
|
+
try { response = await fetch(url, options); }
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err?.name === 'AbortError') throw err;
|
|
75
|
+
throw new ArenaRequestError(summarizeError(err), { category: 'provider', incidents });
|
|
76
|
+
}
|
|
77
|
+
const payload = await response.json().catch(() => ({}));
|
|
78
|
+
if (response.ok) return { response, payload, incidents, retryCount: attempt };
|
|
79
|
+
const message = payload?.error?.message ?? payload?.message ?? `HTTP ${response.status}`;
|
|
80
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
81
|
+
if (retryable && attempt < maxRetries) {
|
|
82
|
+
if (response.status === 429) diagnosticsCounters.rateLimits++; else diagnosticsCounters.providerErrors++;
|
|
83
|
+
incidents.push({ category: requestErrorCategory(response.status), status: response.status, message: String(message).slice(0, 180) });
|
|
84
|
+
await sleepWithSignal(retryDelayMs(response), options?.signal);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (response.status === 429) diagnosticsCounters.rateLimits++; else if (response.status >= 500) diagnosticsCounters.providerErrors++;
|
|
88
|
+
throw new ArenaRequestError(message, { status: response.status, category: requestErrorCategory(response.status), payload, incidents });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function isUnsupportedToolChoiceError(err) {
|
|
92
|
+
const message = summarizeError(err);
|
|
93
|
+
return [400, 404, 422].includes(Number(err?.status)) && /tool[_ -]?choice|tool call|tools?.*(?:unsupported|support)|no endpoints?.*support/i.test(message);
|
|
94
|
+
}
|
|
95
|
+
export function mapGet(mapish, key, fallback = 0) {
|
|
96
|
+
if (mapish instanceof Map) return mapish.get(key) ?? fallback;
|
|
97
|
+
if (Array.isArray(mapish)) {
|
|
98
|
+
const pair = mapish.find(x => Array.isArray(x) && Number(x[0]) === Number(key));
|
|
99
|
+
return pair ? pair[1] : fallback;
|
|
100
|
+
}
|
|
101
|
+
if (mapish && typeof mapish === 'object') return mapish[key] ?? mapish[String(key)] ?? fallback;
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
export function jsonSafe(value) {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(JSON.stringify(value, (_key, v) => v instanceof Map ? Object.fromEntries(v) : v));
|
|
107
|
+
} catch {
|
|
108
|
+
return { unserializable: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Remembers that an OpenRouter endpoint rejected tool_choice so later hands go
|
|
113
|
+
// straight to JSON Schema. Shared so browser and diagnostics behave identically.
|
|
114
|
+
export const protocolCapabilityCache = new Map();
|
|
115
|
+
|
|
116
|
+
// Process-local counters for the diagnostics harness. They make the real
|
|
117
|
+
// request/retry counts explicit without changing any request behaviour.
|
|
118
|
+
export const diagnosticsCounters = { requests: 0, retries: 0, rateLimits: 0, providerErrors: 0 };
|
|
119
|
+
|
|
120
|
+
export function normalizeBaseUrl(baseUrl) { return String(baseUrl || '').trim().replace(/\/+$/, ''); }
|
|
121
|
+
export function completionsUrl(baseUrl) {
|
|
122
|
+
const base = normalizeBaseUrl(baseUrl);
|
|
123
|
+
return /\/chat\/completions$/i.test(base) ? base : `${base}/chat/completions`;
|
|
124
|
+
}
|
|
125
|
+
export function openRouterDecisionsUrl(baseUrl) {
|
|
126
|
+
const base = normalizeBaseUrl(baseUrl);
|
|
127
|
+
if (/\/api\/alpha\/decisions$/i.test(base)) return base;
|
|
128
|
+
try {
|
|
129
|
+
const url = new URL(base);
|
|
130
|
+
if (url.hostname.includes('openrouter.ai')) return `${url.origin}/api/alpha/decisions`;
|
|
131
|
+
} catch {}
|
|
132
|
+
return `${base.replace(/\/api\/v1$/i, '')}/api/alpha/decisions`;
|
|
133
|
+
}
|
|
134
|
+
export function isOpenRouterConnection(connection) {
|
|
135
|
+
if (connection?.kind === 'openrouter') return true;
|
|
136
|
+
try { return new URL(connection?.baseUrl || '').hostname.includes('openrouter.ai'); } catch { return false; }
|
|
137
|
+
}
|
|
138
|
+
export function isJevModel(model) {
|
|
139
|
+
return /^~?typesafe\/jev(?:-|$)/i.test(String(model || '').trim()) || /^jev(?:-|$)/i.test(String(model || '').trim());
|
|
140
|
+
}
|
|
141
|
+
export function isReasoningModel(model) {
|
|
142
|
+
const id = String(model || '').toLowerCase();
|
|
143
|
+
return /(?:^|[\/._:-])(qwen3|qwq|deepseek-(?:r1|v3)|magistral|glm-4|gpt-oss|nemotron|reason(?:ing)?|thinking|o1|o3|o4)(?:$|[\/._:-])/.test(id);
|
|
144
|
+
}
|
|
145
|
+
export function effectiveProtocol(agent, connection) {
|
|
146
|
+
if (isOpenRouterConnection(connection) && isJevModel(agent?.model)) return 'jev_decisions';
|
|
147
|
+
if (connection?.kind === 'typesafe') return 'jev_native';
|
|
148
|
+
return agent?.protocol || 'tool';
|
|
149
|
+
}
|
|
150
|
+
export function modelsUrl(baseUrl) {
|
|
151
|
+
let base = normalizeBaseUrl(baseUrl);
|
|
152
|
+
base = base.replace(/\/chat\/completions$/i, '');
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(base);
|
|
155
|
+
if (url.hostname.includes('openrouter.ai')) return `${url.origin}/api/v1/models?limit=1000&offset=0`;
|
|
156
|
+
} catch {}
|
|
157
|
+
return `${base}/models`;
|
|
158
|
+
}
|
|
159
|
+
export function parseHeaders(text) {
|
|
160
|
+
if (!String(text || '').trim()) return {};
|
|
161
|
+
const obj = JSON.parse(text);
|
|
162
|
+
if (!obj || Array.isArray(obj) || typeof obj !== 'object') throw new Error('Extra headers must be a JSON object');
|
|
163
|
+
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [String(k), String(v)]));
|
|
164
|
+
}
|
|
165
|
+
export function makeHeaders(connection) {
|
|
166
|
+
const headers = { 'Content-Type': 'application/json', ...parseHeaders(connection.headers) };
|
|
167
|
+
if (connection.apiKey) headers.Authorization = `Bearer ${connection.apiKey}`;
|
|
168
|
+
try {
|
|
169
|
+
if (new URL(connection.baseUrl).hostname.includes('openrouter.ai')) {
|
|
170
|
+
headers['X-OpenRouter-Title'] = 'pokertools-arena';
|
|
171
|
+
const loc = typeof location !== 'undefined' ? location : null;
|
|
172
|
+
if (loc && (loc.protocol === 'http:' || loc.protocol === 'https:')) headers['HTTP-Referer'] = loc.href;
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
return headers;
|
|
176
|
+
}
|
|
177
|
+
export function combineAbort(timeoutMs, outerSignal, pauseClock) { const controller = new AbortController();
|
|
178
|
+
let timer = null;
|
|
179
|
+
const abort = () => controller.abort();
|
|
180
|
+
if (outerSignal) {
|
|
181
|
+
if (outerSignal.aborted) controller.abort();
|
|
182
|
+
else outerSignal.addEventListener('abort', abort, { once: true });
|
|
183
|
+
}
|
|
184
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
185
|
+
if (!pauseClock) {
|
|
186
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
187
|
+
} else {
|
|
188
|
+
// Count only active (unpaused) time toward the action deadline, so a
|
|
189
|
+
// paused tournament genuinely returns the paused time to the model
|
|
190
|
+
// instead of letting the request time out while the table is frozen.
|
|
191
|
+
let activeMs = 0;
|
|
192
|
+
let last = Date.now();
|
|
193
|
+
const tick = () => {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
if (!pauseClock.pausedAt) activeMs += now - last;
|
|
196
|
+
last = now;
|
|
197
|
+
if (activeMs >= timeoutMs) { controller.abort(); return; }
|
|
198
|
+
timer = setTimeout(tick, Math.min(200, Math.max(20, timeoutMs - activeMs)));
|
|
199
|
+
};
|
|
200
|
+
timer = setTimeout(tick, Math.min(200, timeoutMs));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
signal: controller.signal,
|
|
205
|
+
cancel() {
|
|
206
|
+
if (timer) clearTimeout(timer);
|
|
207
|
+
outerSignal?.removeEventListener?.('abort', abort);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Single canonical clock/ring phase used by the spectator seat ring and the
|
|
213
|
+
// decision clock. Both are derived from this one function so the ring can never
|
|
214
|
+
// disagree with the displayed number. All thresholds come from configuration.
|
|
215
|
+
export function decisionClockPhase({ baseMs = 0, timeBankMs = 0, elapsedMs = 0, lowTimeMs = 5000, lowTimeFraction = 0.25 } = {}) {
|
|
216
|
+
const base = Math.max(0, asNumber(baseMs));
|
|
217
|
+
const bank = Math.max(0, asNumber(timeBankMs));
|
|
218
|
+
const elapsed = Math.max(0, asNumber(elapsedMs));
|
|
219
|
+
const baseLeft = Math.max(0, base - elapsed);
|
|
220
|
+
const bankUsed = Math.max(0, elapsed - base);
|
|
221
|
+
const bankLeft = Math.max(0, bank - bankUsed);
|
|
222
|
+
// The visible clock and the ring share one phase: base action time, then the
|
|
223
|
+
// time bank. They must never be computed against different totals.
|
|
224
|
+
const inBank = base <= 0 || elapsed >= base;
|
|
225
|
+
const phaseTotal = inBank ? bank : base;
|
|
226
|
+
const phaseRemaining = inBank ? bankLeft : baseLeft;
|
|
227
|
+
const shownMs = inBank ? bankLeft : baseLeft;
|
|
228
|
+
const lowSecondsMs = Math.max(0, asNumber(lowTimeMs));
|
|
229
|
+
const lowFraction = clamp(lowTimeFraction, 0, 1);
|
|
230
|
+
const lowThreshold = phaseTotal > 0 ? Math.min(lowSecondsMs, phaseTotal * lowFraction) : 0;
|
|
231
|
+
const ringFraction = phaseTotal > 0 ? clamp(phaseRemaining / phaseTotal, 0, 1) : 0;
|
|
232
|
+
return {
|
|
233
|
+
baseLeft, bankLeft, inBank, phaseTotal, phaseRemaining, shownMs, lowThreshold, ringFraction,
|
|
234
|
+
isLow: phaseTotal > 0 && phaseRemaining > 0 && phaseRemaining <= lowThreshold,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function playerCards(player) {
|
|
239
|
+
if (!player) return [];
|
|
240
|
+
const raw = player.cards ?? player.holeCards ?? player.hand ?? [];
|
|
241
|
+
return Array.isArray(raw) ? raw.filter(Boolean).map(String) : [];
|
|
242
|
+
}
|
|
243
|
+
export function playerStack(player) { return asNumber(player?.stack ?? player?.chips ?? 0); }
|
|
244
|
+
export function currentBet(state, seat) { return asNumber(mapGet(state?.currentBets, seat, state?.players?.[seat]?.bet ?? 0)); }
|
|
245
|
+
export function totalPot(state) {
|
|
246
|
+
const pots = Array.isArray(state?.pots) ? state.pots : [];
|
|
247
|
+
const settled = pots.reduce((sum, p) => sum + asNumber(p?.amount ?? p?.size ?? 0), 0);
|
|
248
|
+
let live = 0;
|
|
249
|
+
const bets = state?.currentBets;
|
|
250
|
+
if (bets instanceof Map) for (const amount of bets.values()) live += asNumber(amount);
|
|
251
|
+
else if (Array.isArray(bets)) for (const entry of bets) live += asNumber(Array.isArray(entry) ? entry[1] : entry?.amount);
|
|
252
|
+
else if (bets && typeof bets === 'object') for (const amount of Object.values(bets)) live += asNumber(amount);
|
|
253
|
+
return settled + live;
|
|
254
|
+
}
|
|
255
|
+
export function clockwiseSeats(state) {
|
|
256
|
+
const seated = (state?.players ?? []).map((p, seat) => ({ p, seat }))
|
|
257
|
+
.filter(({ p }) => p && p.status !== 'BUSTED' && p.status !== 'SITTING_OUT')
|
|
258
|
+
.map(({ seat }) => seat).sort((a, b) => a - b);
|
|
259
|
+
if (!seated.length || state?.buttonSeat == null) return seated;
|
|
260
|
+
const idx = seated.indexOf(state.buttonSeat);
|
|
261
|
+
if (idx < 0) return seated;
|
|
262
|
+
return [...seated.slice(idx), ...seated.slice(0, idx)];
|
|
263
|
+
}
|
|
264
|
+
export const POSITION_TABLE = {
|
|
265
|
+
2: ['BTN/SB', 'BB'], 3: ['BTN', 'SB', 'BB'], 4: ['BTN', 'SB', 'BB', 'UTG'],
|
|
266
|
+
5: ['BTN', 'SB', 'BB', 'UTG', 'CO'], 6: ['BTN', 'SB', 'BB', 'UTG', 'HJ', 'CO'],
|
|
267
|
+
7: ['BTN', 'SB', 'BB', 'UTG', 'MP', 'HJ', 'CO'], 8: ['BTN', 'SB', 'BB', 'UTG', 'UTG+1', 'MP', 'HJ', 'CO'],
|
|
268
|
+
9: ['BTN', 'SB', 'BB', 'UTG', 'UTG+1', 'MP', 'MP+1', 'HJ', 'CO'],
|
|
269
|
+
10: ['BTN', 'SB', 'BB', 'UTG', 'UTG+1', 'UTG+2', 'MP', 'MP+1', 'HJ', 'CO'],
|
|
270
|
+
};
|
|
271
|
+
export function positionForSeat(state, seat) {
|
|
272
|
+
const order = clockwiseSeats(state);
|
|
273
|
+
const labels = POSITION_TABLE[order.length] ?? order.map((_, i) => i === 0 ? 'BTN' : `P${i}`);
|
|
274
|
+
const idx = order.indexOf(seat);
|
|
275
|
+
return idx >= 0 ? labels[idx] : `Seat ${seat + 1}`;
|
|
276
|
+
}
|
|
277
|
+
export function describeAction(type, amount, state, seat = null) {
|
|
278
|
+
const bb = Math.max(1, asNumber(state.bigBlind, 1));
|
|
279
|
+
const heroBet = seat == null ? 0 : Math.max(0, currentBet(state, seat));
|
|
280
|
+
const highestBet = Math.max(0, ...(state.players ?? []).map((_, s) => currentBet(state, s)));
|
|
281
|
+
const toCall = Math.max(0, highestBet - heroBet);
|
|
282
|
+
const stack = seat == null ? 0 : Math.max(0, playerStack(state.players?.[seat]));
|
|
283
|
+
switch (type) {
|
|
284
|
+
case ACTION.FOLD: return 'Fold';
|
|
285
|
+
case ACTION.CHECK: return 'Check';
|
|
286
|
+
case ACTION.CALL: {
|
|
287
|
+
if (!toCall) return 'Call';
|
|
288
|
+
const effective = Math.min(stack, toCall);
|
|
289
|
+
const suffix = effective < toCall ? ' (all-in)' : '';
|
|
290
|
+
return `Call ${effective}${suffix} (${round(effective / bb, 1)} BB)`;
|
|
291
|
+
}
|
|
292
|
+
case ACTION.BET: return `Bet ${amount} (${round(amount / bb, 1)} BB)`;
|
|
293
|
+
case ACTION.RAISE: {
|
|
294
|
+
const maxTotal = heroBet + stack;
|
|
295
|
+
if (seat != null && amount >= maxTotal) return `Raise to ${maxTotal} (all-in)`;
|
|
296
|
+
return `Raise to ${amount} (${round(amount / bb, 1)} BB)`;
|
|
297
|
+
}
|
|
298
|
+
default: return type;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
export function tryCandidate(engine, seat, playerId, type, amount, out, seen) {
|
|
302
|
+
const action = { type, playerId };
|
|
303
|
+
if (amount != null) action.amount = Math.max(1, Math.round(amount));
|
|
304
|
+
let validation;
|
|
305
|
+
try { validation = engine.validate(action); } catch { return; }
|
|
306
|
+
if (!validation?.valid) return;
|
|
307
|
+
const key = `${type}:${action.amount ?? ''}`;
|
|
308
|
+
if (seen.has(key)) return;
|
|
309
|
+
seen.add(key);
|
|
310
|
+
out.push({ id: `A${out.length}`, type, amount: action.amount ?? null, description: describeAction(type, action.amount, engine.state, seat), engineAction: action });
|
|
311
|
+
}
|
|
312
|
+
export function legalActionCandidates(engine, seat) {
|
|
313
|
+
const state = engine.state;
|
|
314
|
+
const player = state.players?.[seat];
|
|
315
|
+
if (!player) return [];
|
|
316
|
+
const out = [], seen = new Set();
|
|
317
|
+
const stack = Math.max(0, playerStack(player));
|
|
318
|
+
const bet = Math.max(0, currentBet(state, seat));
|
|
319
|
+
const bb = Math.max(1, asNumber(state.bigBlind, 1));
|
|
320
|
+
const sb = Math.max(1, asNumber(state.smallBlind, Math.ceil(bb / 2)));
|
|
321
|
+
const pot = Math.max(bb, totalPot(state));
|
|
322
|
+
const minRaise = Math.max(1, asNumber(state.minRaise, bb));
|
|
323
|
+
const highestBet = Math.max(0, ...(state.players ?? []).map((_, s) => currentBet(state, s)));
|
|
324
|
+
const lastRaise = Math.max(bb, asNumber(state.lastRaiseAmount, bb));
|
|
325
|
+
const toCall = Math.max(0, highestBet - bet);
|
|
326
|
+
// FOLD is strictly dominated by CHECK when checking costs nothing: checking
|
|
327
|
+
// preserves every showdown and future-betting outcome, while folding forfeits
|
|
328
|
+
// the hand for free. Never expose the dominated option to a decision model.
|
|
329
|
+
const passiveTypes = [ACTION.FOLD, ACTION.CHECK, ACTION.CALL].filter(type => !(type === ACTION.FOLD && toCall === 0));
|
|
330
|
+
for (const type of passiveTypes) tryCandidate(engine, seat, player.id, type, null, out, seen);
|
|
331
|
+
// PokerTools player.stack is the uncommitted stack; an aggressive amount is a total
|
|
332
|
+
// contribution on the street, so it must never exceed currentBet + remaining stack.
|
|
333
|
+
const maxTotal = bet + stack;
|
|
334
|
+
const effectiveCall = Math.min(stack, toCall);
|
|
335
|
+
const call = out.find(a => a.type === ACTION.CALL);
|
|
336
|
+
if (call && toCall > 0) {
|
|
337
|
+
const suffix = effectiveCall < toCall ? ' (all-in)' : '';
|
|
338
|
+
call.description = `Call ${effectiveCall}${suffix} (${round(effectiveCall / bb, 1)} BB)`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// BET and RAISE are mutually exclusive semantic choices. Once any wager exists on
|
|
342
|
+
// the street (including blinds preflop), further aggression is a RAISE, not a BET.
|
|
343
|
+
// Arena-side semantic guards intentionally sit on top of engine.validate(): a short
|
|
344
|
+
// stack that cannot reach the current price must only see CALL/FOLD, never a pseudo-raise.
|
|
345
|
+
const aggressiveType = highestBet > 0 ? ACTION.RAISE : ACTION.BET;
|
|
346
|
+
const minRaiseTo = highestBet + minRaise;
|
|
347
|
+
const canAggress = aggressiveType === ACTION.BET ? stack > 0 : maxTotal > highestBet && maxTotal >= minRaiseTo;
|
|
348
|
+
const rawAmounts = [
|
|
349
|
+
sb, bb, 2 * bb, 2.5 * bb, 3 * bb, 4 * bb, minRaiseTo, highestBet + lastRaise,
|
|
350
|
+
highestBet * 2, highestBet + Math.round(pot * 0.33), highestBet + Math.round(pot * 0.5), highestBet + Math.round(pot * 0.75),
|
|
351
|
+
highestBet + pot, Math.round(pot * 0.33), Math.round(pot * 0.5), Math.round(pot * 0.75), pot, maxTotal,
|
|
352
|
+
].filter(n => Number.isFinite(n) && n > 0 && n <= maxTotal && (aggressiveType !== ACTION.RAISE || n > highestBet));
|
|
353
|
+
const amounts = [...new Set(rawAmounts.map(n => Math.max(1, Math.round(n))))].sort((a, b) => a - b);
|
|
354
|
+
if (canAggress) for (const amount of amounts) tryCandidate(engine, seat, player.id, aggressiveType, amount, out, seen);
|
|
355
|
+
|
|
356
|
+
const passive = out.filter(a => ![ACTION.BET, ACTION.RAISE].includes(a.type));
|
|
357
|
+
const aggressive = out.filter(a => [ACTION.BET, ACTION.RAISE].includes(a.type));
|
|
358
|
+
const selected = [];
|
|
359
|
+
if (aggressive.length <= 6) selected.push(...aggressive);
|
|
360
|
+
else {
|
|
361
|
+
const picks = [0, 1, Math.floor((aggressive.length - 1) * 0.33), Math.floor((aggressive.length - 1) * 0.66), aggressive.length - 2, aggressive.length - 1];
|
|
362
|
+
for (const idx of [...new Set(picks)]) if (aggressive[idx]) selected.push(aggressive[idx]);
|
|
363
|
+
}
|
|
364
|
+
return [...passive, ...selected].map((a, index) => ({ ...a, id: `A${index}` }));
|
|
365
|
+
}
|
|
366
|
+
export function fallbackAction(legalActions) {
|
|
367
|
+
for (const type of [ACTION.CHECK, ACTION.FOLD, ACTION.CALL]) {
|
|
368
|
+
const found = legalActions.find(a => a.type === type);
|
|
369
|
+
if (found) return found;
|
|
370
|
+
}
|
|
371
|
+
return legalActions[0] ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function serializeForAgent(engine, seat, tournamentMeta, legalActions, recentHands = [], publicPlayerStats = [], actionHistory = []) {
|
|
375
|
+
const full = engine.state;
|
|
376
|
+
const player = full.players?.[seat];
|
|
377
|
+
if (!player?.id) throw new Error(`Cannot build decision context: no player at seat ${seat}`);
|
|
378
|
+
// Fail closed. A failed player view must never fall back to the omniscient server state.
|
|
379
|
+
const view = engine.view(player.id);
|
|
380
|
+
const vp = view?.players?.[seat];
|
|
381
|
+
if (!vp) throw new Error(`Cannot build masked decision context for ${player.id}`);
|
|
382
|
+
const stack = playerStack(vp);
|
|
383
|
+
const bb = Math.max(1, asNumber(full.bigBlind, 1));
|
|
384
|
+
const eliminated = new Set(tournamentMeta.eliminatedPlayerIds ?? []);
|
|
385
|
+
return {
|
|
386
|
+
contextVersion: DECISION_CONTEXT_VERSION,
|
|
387
|
+
informationPolicy: INFORMATION_POLICY,
|
|
388
|
+
objective: DECISION_OBJECTIVE,
|
|
389
|
+
game: 'No-Limit Texas Holdem tournament',
|
|
390
|
+
memoryPolicy: {
|
|
391
|
+
currentHand: 'all public model actions in the current hand before this decision',
|
|
392
|
+
recentHands: `last ${RECENT_PUBLIC_HANDS} completed public hands`,
|
|
393
|
+
publicPlayerStats: 'deterministic aggregates from completed hands before the current hand; identical public dataset for every seat',
|
|
394
|
+
},
|
|
395
|
+
tournament: { handNumber: tournamentMeta.handNumber, blindLevel: asNumber(full.blindLevel, tournamentMeta.levelIndex), playersRemaining: tournamentMeta.playersRemaining, startingPlayers: tournamentMeta.startingPlayers },
|
|
396
|
+
blinds: { smallBlind: asNumber(full.smallBlind), bigBlind: asNumber(full.bigBlind), ante: asNumber(full.ante) },
|
|
397
|
+
hero: { id: player.id, name: player.name, seat: seat + 1, position: positionForSeat(full, seat), stack, stackBB: round(stack / bb, 1), cards: playerCards(vp), currentBet: currentBet(full, seat) },
|
|
398
|
+
betting: (() => {
|
|
399
|
+
const heroCurrentBet = currentBet(full, seat);
|
|
400
|
+
const highestBet = Math.max(0, ...(full.players ?? []).map((_, s) => currentBet(full, s)));
|
|
401
|
+
const toCall = Math.max(0, highestBet - heroCurrentBet);
|
|
402
|
+
return { highestBet, heroCurrentBet, toCall, effectiveCall: Math.min(stack, toCall), stackBehind: stack, facingAllInCall: stack > 0 && stack <= toCall };
|
|
403
|
+
})(),
|
|
404
|
+
board: Array.isArray(view?.board) ? view.board : [], street: full.street, pot: totalPot(full), buttonSeat: full.buttonSeat == null ? null : full.buttonSeat + 1,
|
|
405
|
+
actionHistory,
|
|
406
|
+
recentHands,
|
|
407
|
+
publicPlayerStats,
|
|
408
|
+
opponents: (view.players ?? []).map((p, s) => p && s !== seat && !eliminated.has(p.id) ? {
|
|
409
|
+
id: p.id, seat: s + 1, name: p.name, position: positionForSeat(full, s), stack: playerStack(p), stackBB: round(playerStack(p) / bb, 1), currentBet: currentBet(full, s),
|
|
410
|
+
status: p.status ?? (p.folded ? 'FOLDED' : 'ACTIVE'), cards: playerCards(p),
|
|
411
|
+
} : null).filter(Boolean),
|
|
412
|
+
legalActions: legalActions.map(({ id: actionId, type, amount, description }) => ({ id: actionId, type, amount, description })),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
export function assertDecisionState(state) {
|
|
416
|
+
if (!state || state.contextVersion !== DECISION_CONTEXT_VERSION) throw new Error('Decision context version mismatch');
|
|
417
|
+
if (!Array.isArray(state.hero?.cards) || state.hero.cards.length !== 2) throw new Error('Decision context must expose exactly two hero cards');
|
|
418
|
+
if (state.heroHand != null) {
|
|
419
|
+
if (typeof state.heroHand !== 'object' || !state.heroHand.category) throw new Error('Deterministic heroHand must carry a category');
|
|
420
|
+
if (JSON.stringify(state.heroHand).match(/opponent|villain/i)) throw new Error('Deterministic heroHand must describe the hero only');
|
|
421
|
+
}
|
|
422
|
+
if (!Array.isArray(state.opponents) || state.opponents.some(p => !Array.isArray(p.cards) || p.cards.length !== 0)) throw new Error('Decision context leaked opponent hole cards');
|
|
423
|
+
if (!Array.isArray(state.publicPlayerStats)) throw new Error('Decision context is missing publicPlayerStats');
|
|
424
|
+
const ids = new Set();
|
|
425
|
+
const aggressiveByAmount = new Map();
|
|
426
|
+
const maxTotal = asNumber(state.hero.stack) + asNumber(state.hero.currentBet);
|
|
427
|
+
for (const action of state.legalActions ?? []) {
|
|
428
|
+
if (!action?.id || ids.has(action.id)) throw new Error('Decision context has duplicate or missing action IDs');
|
|
429
|
+
ids.add(action.id);
|
|
430
|
+
if (['BET','RAISE'].includes(action.type)) {
|
|
431
|
+
if (!Number.isFinite(Number(action.amount)) || Number(action.amount) > maxTotal) throw new Error(`Aggressive action exceeds available chips: ${action.id}`);
|
|
432
|
+
if (action.type === 'RAISE' && Number(action.amount) <= Number(state.betting?.highestBet ?? 0)) throw new Error(`Raise does not exceed the current price: ${action.id}`);
|
|
433
|
+
const prior = aggressiveByAmount.get(Number(action.amount));
|
|
434
|
+
if (prior && prior !== action.type) throw new Error(`Decision context exposes BET and RAISE for the same amount: ${action.amount}`);
|
|
435
|
+
aggressiveByAmount.set(Number(action.amount), action.type);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const expectedRemaining = 1 + state.opponents.length;
|
|
439
|
+
if (Number(state.tournament?.playersRemaining) !== expectedRemaining) throw new Error(`playersRemaining mismatch: expected ${expectedRemaining}, got ${state.tournament?.playersRemaining}`);
|
|
440
|
+
return state;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function extractTextContent(message) {
|
|
444
|
+
const c = message?.content;
|
|
445
|
+
if (typeof c === 'string') return c;
|
|
446
|
+
if (Array.isArray(c)) return c.map(x => typeof x === 'string' ? x : (x?.text ?? '')).join('');
|
|
447
|
+
return '';
|
|
448
|
+
}
|
|
449
|
+
export function stripCodeFence(text) {
|
|
450
|
+
const s = String(text || '').trim();
|
|
451
|
+
const m = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
452
|
+
return m ? m[1] : s;
|
|
453
|
+
}
|
|
454
|
+
export function normalizeDecisionObject(obj, legalActions, decisionId) {
|
|
455
|
+
if (!obj || typeof obj !== 'object') throw new Error('Model returned no decision object');
|
|
456
|
+
if (obj.decisionId && obj.decisionId !== decisionId) throw new Error('Stale/wrong decisionId');
|
|
457
|
+
const action = legalActions.find(a => a.id === obj.actionId);
|
|
458
|
+
if (!action) throw new Error(`Unknown actionId: ${obj.actionId}`);
|
|
459
|
+
return { action, publicReason: String(obj.publicReason || '').slice(0, 220) };
|
|
460
|
+
}
|
|
461
|
+
export function pokerPrompt(state) {
|
|
462
|
+
return [
|
|
463
|
+
'You are one autonomous player in a No-Limit Texas Holdem tournament.',
|
|
464
|
+
DECISION_OBJECTIVE,
|
|
465
|
+
'The JSON state below is the complete information available to you for this decision. Do not assume hidden cards or private information.',
|
|
466
|
+
'publicReason must be a short spectator-facing explanation (max 220 characters), not hidden chain-of-thought or private scratch work.',
|
|
467
|
+
'', JSON.stringify(state),
|
|
468
|
+
].join('\n');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// Deterministic hand evaluation (code-generated, never model-generated).
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
export const HAND_CATEGORY_KEYS = Object.freeze([
|
|
475
|
+
'high_card', 'pair', 'two_pair', 'trips', 'straight', 'flush', 'full_house', 'quads', 'straight_flush',
|
|
476
|
+
]);
|
|
477
|
+
export const HAND_CATEGORY_LABELS = Object.freeze({
|
|
478
|
+
high_card: 'High card', pair: 'One pair', two_pair: 'Two pair', trips: 'Three of a kind',
|
|
479
|
+
straight: 'Straight', flush: 'Flush', full_house: 'Full house', quads: 'Four of a kind', straight_flush: 'Straight flush',
|
|
480
|
+
});
|
|
481
|
+
const CATEGORY_KEY_BY_DESCRIPTION = Object.freeze({
|
|
482
|
+
'High Card': 'high_card',
|
|
483
|
+
'One Pair': 'pair',
|
|
484
|
+
'Two Pair': 'two_pair',
|
|
485
|
+
'Three of a Kind': 'trips',
|
|
486
|
+
'Straight': 'straight',
|
|
487
|
+
'Flush': 'flush',
|
|
488
|
+
'Full House': 'full_house',
|
|
489
|
+
'Four of a Kind': 'quads',
|
|
490
|
+
'Straight Flush': 'straight_flush',
|
|
491
|
+
});
|
|
492
|
+
export function heroHandCategory(heroCards, board) {
|
|
493
|
+
const cards = [...(heroCards ?? []), ...(board ?? [])].filter(Boolean);
|
|
494
|
+
if (cards.length < 5) return null;
|
|
495
|
+
const rankValue = rank(getCardCodes(cards));
|
|
496
|
+
const description = rankDescription(rankValue);
|
|
497
|
+
return { description, key: CATEGORY_KEY_BY_DESCRIPTION[description] ?? null, rank: rankValue };
|
|
498
|
+
}
|
|
499
|
+
export function heroHandSummary(heroCards, board) {
|
|
500
|
+
const category = heroHandCategory(heroCards, board);
|
|
501
|
+
if (!category) return null;
|
|
502
|
+
return { category: category.description, description: `${category.description} (deterministic)`, key: category.key };
|
|
503
|
+
}
|
|
504
|
+
export function handCategoryCriteria() {
|
|
505
|
+
return Object.fromEntries(HAND_CATEGORY_KEYS.map(key => [key, HAND_CATEGORY_LABELS[key]]));
|
|
506
|
+
}
|
|
507
|
+
export { HAND_RANK_DESCRIPTIONS };
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Benchmark modes and decision architecture.
|
|
511
|
+
//
|
|
512
|
+
// The benchmark mode is a tournament-wide setting: either every model receives
|
|
513
|
+
// a deterministic hero-hand classification (Strategy) or every model receives
|
|
514
|
+
// raw cards and must infer strength itself (Raw cognition). It can never vary
|
|
515
|
+
// by seat.
|
|
516
|
+
//
|
|
517
|
+
// The decision architecture is also tournament-wide. The hierarchical
|
|
518
|
+
// architecture asks every model for an action family first, then (only for
|
|
519
|
+
// BET/RAISE) for a size from the same deterministic, engine-validated size
|
|
520
|
+
// set. The flat legacy architecture is retained only as a diagnostic baseline.
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
export const BENCHMARK_MODES = Object.freeze({ STRATEGY: 'strategy', RAW: 'raw' });
|
|
523
|
+
export const DECISION_ARCHITECTURES = Object.freeze({ HIERARCHICAL: 'hierarchical', FLAT: 'flat' });
|
|
524
|
+
export const DEFAULT_BENCHMARK_MODE = BENCHMARK_MODES.STRATEGY;
|
|
525
|
+
export const DEFAULT_DECISION_ARCHITECTURE = DECISION_ARCHITECTURES.HIERARCHICAL;
|
|
526
|
+
export const DECISION_ARCHITECTURE_VERSION = 'hierarchical-v1';
|
|
527
|
+
export const REPRESENTATION_MODES = Object.freeze(['canonical_json', 'compact_json', 'markdown']);
|
|
528
|
+
export const DEFAULT_REPRESENTATION_MODE = 'canonical_json';
|
|
529
|
+
|
|
530
|
+
export const ACTION_FAMILY = Object.freeze({
|
|
531
|
+
FOLD: 'fold', CHECK: 'check', CALL: 'call', BET: 'bet', RAISE: 'raise',
|
|
532
|
+
});
|
|
533
|
+
export const FAMILY_LABELS = Object.freeze({ fold: 'Fold', check: 'Check', call: 'Call', bet: 'Bet', raise: 'Raise' });
|
|
534
|
+
export const SIZE_IDS = Object.freeze(['small', 'medium', 'large', 'all_in']);
|
|
535
|
+
export const SIZE_LABELS = Object.freeze({ small: 'SMALL', medium: 'MEDIUM', large: 'LARGE', all_in: 'ALL-IN' });
|
|
536
|
+
|
|
537
|
+
export function isAggressiveType(type) { return type === ACTION.BET || type === ACTION.RAISE; }
|
|
538
|
+
export function familyForActionType(type) {
|
|
539
|
+
if (type === ACTION.BET) return ACTION_FAMILY.BET;
|
|
540
|
+
if (type === ACTION.RAISE) return ACTION_FAMILY.RAISE;
|
|
541
|
+
if (type === ACTION.CHECK) return ACTION_FAMILY.CHECK;
|
|
542
|
+
if (type === ACTION.CALL) return ACTION_FAMILY.CALL;
|
|
543
|
+
if (type === ACTION.FOLD) return ACTION_FAMILY.FOLD;
|
|
544
|
+
return String(type || '').toLowerCase();
|
|
545
|
+
}
|
|
546
|
+
export function actionTypeForFamily(family) {
|
|
547
|
+
switch (String(family).toLowerCase()) {
|
|
548
|
+
case ACTION_FAMILY.FOLD: return ACTION.FOLD;
|
|
549
|
+
case ACTION_FAMILY.CHECK: return ACTION.CHECK;
|
|
550
|
+
case ACTION_FAMILY.CALL: return ACTION.CALL;
|
|
551
|
+
case ACTION_FAMILY.BET: return ACTION.BET;
|
|
552
|
+
case ACTION_FAMILY.RAISE: return ACTION.RAISE;
|
|
553
|
+
default: return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Deterministic hand field is added (or deliberately withheld) per benchmark
|
|
558
|
+
// mode. It always describes the hero's best five-card hand only.
|
|
559
|
+
export function applyBenchmarkMode(state, mode = DEFAULT_BENCHMARK_MODE) {
|
|
560
|
+
if (!state || mode !== BENCHMARK_MODES.STRATEGY) return state;
|
|
561
|
+
const hand = heroHandSummary(state.hero?.cards ?? state.heroCards, state.board);
|
|
562
|
+
return hand ? { ...state, heroHand: hand } : { ...state };
|
|
563
|
+
}
|
|
564
|
+
export function stripHeroHand(state) {
|
|
565
|
+
if (!state || state.heroHand == null) return state;
|
|
566
|
+
const { heroHand, ...rest } = state;
|
|
567
|
+
return rest;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// ACTION FAMILY GENERATION
|
|
572
|
+
//
|
|
573
|
+
// Derived from the actual legal actions, never from prose. The result is the
|
|
574
|
+
// single canonical family set for a decision state. Assertions are deliberately
|
|
575
|
+
// strict: an arena bug that exposes BET and RAISE together, or FOLD while CHECK
|
|
576
|
+
// is free, must fail loudly rather than silently bias the benchmark.
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
export function legalActionFamilies(legalActions, { toCall = 0 } = {}) {
|
|
579
|
+
const actions = Array.isArray(legalActions) ? legalActions : [];
|
|
580
|
+
const has = type => actions.some(a => a?.type === type);
|
|
581
|
+
const aggressiveTypes = [...new Set(actions.filter(a => isAggressiveType(a?.type)).map(a => a.type))];
|
|
582
|
+
if (aggressiveTypes.length > 1) throw new Error('Decision state exposes both BET and RAISE simultaneously');
|
|
583
|
+
const families = [];
|
|
584
|
+
if (asNumber(toCall) > 0) {
|
|
585
|
+
if (has(ACTION.FOLD)) families.push(ACTION_FAMILY.FOLD);
|
|
586
|
+
if (has(ACTION.CALL)) families.push(ACTION_FAMILY.CALL);
|
|
587
|
+
if (aggressiveTypes[0]) families.push(familyForActionType(aggressiveTypes[0]));
|
|
588
|
+
if (has(ACTION.CHECK)) throw new Error('Decision state exposes CHECK while facing a bet');
|
|
589
|
+
} else {
|
|
590
|
+
if (has(ACTION.FOLD)) throw new Error('Decision state exposes FOLD while CHECK is free (strictly dominated)');
|
|
591
|
+
if (has(ACTION.CHECK)) families.push(ACTION_FAMILY.CHECK);
|
|
592
|
+
if (aggressiveTypes[0]) families.push(familyForActionType(aggressiveTypes[0]));
|
|
593
|
+
}
|
|
594
|
+
return families;
|
|
595
|
+
}
|
|
596
|
+
export function familyCriteria(families) {
|
|
597
|
+
return Object.fromEntries((families ?? []).map(family => [family, FAMILY_LABELS[family] ?? family]));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
// DETERMINISTIC SIZING CANDIDATES
|
|
602
|
+
//
|
|
603
|
+
// At most four strategically distinct buckets: SMALL, MEDIUM, LARGE, ALL_IN.
|
|
604
|
+
// Amounts are clamped to the available stack, respect the minimum raise/bet,
|
|
605
|
+
// deduplicated, and near-duplicates are removed. The all-in bucket is kept only
|
|
606
|
+
// when it is materially larger than the remaining candidates.
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
const SIZE_NEAR_FRACTION = 0.15;
|
|
609
|
+
|
|
610
|
+
export function stateDecisionDescriptor(state) {
|
|
611
|
+
const full = state ?? {};
|
|
612
|
+
const betting = full.betting ?? {};
|
|
613
|
+
const bb = Math.max(1, asNumber(full.blinds?.bigBlind ?? full.bigBlind, 1));
|
|
614
|
+
const sb = Math.max(1, asNumber(full.blinds?.smallBlind ?? full.smallBlind, Math.ceil(bb / 2)));
|
|
615
|
+
const heroStack = Math.max(0, asNumber(full.hero?.stack ?? full.heroStack, 0));
|
|
616
|
+
const heroCurrentBet = Math.max(0, asNumber(full.hero?.currentBet ?? betting.heroCurrentBet, 0));
|
|
617
|
+
const highestBet = Math.max(0, asNumber(betting.highestBet, 0));
|
|
618
|
+
const toCall = Math.max(0, asNumber(betting.toCall, highestBet - heroCurrentBet));
|
|
619
|
+
const pot = Math.max(bb, asNumber(full.pot, bb));
|
|
620
|
+
const minRaise = Math.max(bb, asNumber(betting.minRaise ?? full.minRaise, bb));
|
|
621
|
+
const lastRaise = Math.max(0, asNumber(betting.lastRaiseAmount ?? full.lastRaiseAmount, 0));
|
|
622
|
+
return { bb, sb, heroStack, heroCurrentBet, highestBet, toCall, pot, minRaise: Math.max(minRaise, lastRaise), maxTotal: heroCurrentBet + heroStack };
|
|
623
|
+
}
|
|
624
|
+
export function engineDecisionDescriptor(engine, seat) {
|
|
625
|
+
const state = engine.state;
|
|
626
|
+
const player = state?.players?.[seat];
|
|
627
|
+
if (!player) throw new Error(`No player at seat ${seat}`);
|
|
628
|
+
const bb = Math.max(1, asNumber(state.bigBlind, 1));
|
|
629
|
+
const sb = Math.max(1, asNumber(state.smallBlind, Math.ceil(bb / 2)));
|
|
630
|
+
const heroStack = Math.max(0, playerStack(player));
|
|
631
|
+
const heroCurrentBet = Math.max(0, currentBet(state, seat));
|
|
632
|
+
const highestBet = Math.max(0, ...(state.players ?? []).map((_, s) => currentBet(state, s)));
|
|
633
|
+
const toCall = Math.max(0, highestBet - heroCurrentBet);
|
|
634
|
+
const pot = Math.max(bb, totalPot(state));
|
|
635
|
+
const minRaise = Math.max(bb, asNumber(state.minRaise, bb));
|
|
636
|
+
const lastRaise = Math.max(0, asNumber(state.lastRaiseAmount, 0));
|
|
637
|
+
return { bb, sb, heroStack, heroCurrentBet, highestBet, toCall, pot, minRaise: Math.max(minRaise, lastRaise), maxTotal: heroCurrentBet + heroStack };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export function formatChips(n) { return Number(Math.max(0, Math.round(asNumber(n)))).toLocaleString('en-US'); }
|
|
641
|
+
function sizeLabel(type, amount, descriptor, sizeId) {
|
|
642
|
+
const isAllIn = sizeId === SIZE_IDS[3] || Math.round(amount) >= Math.round(descriptor.maxTotal);
|
|
643
|
+
const verb = type === ACTION.RAISE ? 'Raise to' : 'Bet';
|
|
644
|
+
if (isAllIn) return `${verb} ${formatChips(amount)} chips all-in`;
|
|
645
|
+
const pct = descriptor.pot > 0 ? Math.round((amount / descriptor.pot) * 100) : 0;
|
|
646
|
+
return `${verb} ${formatChips(amount)} chips (${pct}% pot)`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Pure planner. `validate(amount)` lets the engine reject illegal amounts; when
|
|
650
|
+
// omitted the planner still clamps to the descriptor's own limits.
|
|
651
|
+
export function planAggressiveSizes({ family, descriptor, validate = null }) {
|
|
652
|
+
if (![ACTION_FAMILY.BET, ACTION_FAMILY.RAISE].includes(family)) return [];
|
|
653
|
+
const type = family === ACTION_FAMILY.RAISE ? ACTION.RAISE : ACTION.BET;
|
|
654
|
+
const d = descriptor;
|
|
655
|
+
const maxTotal = Math.max(0, Math.floor(asNumber(d.maxTotal)));
|
|
656
|
+
if (maxTotal <= 0) return [];
|
|
657
|
+
const minLegal = type === ACTION.RAISE
|
|
658
|
+
? Math.max(Math.floor(asNumber(d.highestBet)) + Math.max(1, Math.floor(asNumber(d.minRaise))), Math.floor(asNumber(d.highestBet)) + 1)
|
|
659
|
+
: Math.max(1, Math.min(Math.floor(asNumber(d.bb)), maxTotal));
|
|
660
|
+
if (minLegal > maxTotal) return [];
|
|
661
|
+
const base = type === ACTION.RAISE ? Math.max(0, Math.floor(asNumber(d.highestBet))) : 0;
|
|
662
|
+
const pot = Math.max(1, asNumber(d.pot));
|
|
663
|
+
const rawTargets = [
|
|
664
|
+
{ id: SIZE_IDS[0], amount: base + Math.round(pot * 0.33) },
|
|
665
|
+
{ id: SIZE_IDS[1], amount: base + Math.round(pot * 0.67) },
|
|
666
|
+
{ id: SIZE_IDS[2], amount: base + Math.round(pot * 1.0) },
|
|
667
|
+
{ id: SIZE_IDS[3], amount: maxTotal },
|
|
668
|
+
];
|
|
669
|
+
const near = Math.max(2, Math.round(Math.min(Math.max(1, asNumber(d.bb)), pot) * SIZE_NEAR_FRACTION));
|
|
670
|
+
const kept = [];
|
|
671
|
+
for (const target of rawTargets) {
|
|
672
|
+
let amount = clamp(Math.round(target.amount), minLegal, maxTotal);
|
|
673
|
+
if (amount < minLegal || amount > maxTotal) continue;
|
|
674
|
+
if (validate && !validate(amount)) continue;
|
|
675
|
+
if (kept.some(entry => Math.abs(entry.amount - amount) <= near)) continue;
|
|
676
|
+
kept.push({ id: target.id, amount });
|
|
677
|
+
}
|
|
678
|
+
if (!kept.length && maxTotal >= minLegal && (!validate || validate(maxTotal))) kept.push({ id: SIZE_IDS[3], amount: maxTotal });
|
|
679
|
+
// If a non-all-in bucket already sits at the stack cap, it *is* the all-in.
|
|
680
|
+
for (const entry of kept) if (entry.amount >= maxTotal) entry.id = SIZE_IDS[3];
|
|
681
|
+
const seenIds = new Set();
|
|
682
|
+
const deduped = [];
|
|
683
|
+
for (const entry of kept) {
|
|
684
|
+
if (seenIds.has(entry.id)) continue;
|
|
685
|
+
seenIds.add(entry.id);
|
|
686
|
+
deduped.push({ id: entry.id, amount: entry.amount, label: sizeLabel(type, entry.amount, d, entry.id) });
|
|
687
|
+
}
|
|
688
|
+
return deduped;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export function aggressiveSizesForState(state, family, { validate = null } = {}) {
|
|
692
|
+
return planAggressiveSizes({ family, descriptor: stateDecisionDescriptor(state), validate });
|
|
693
|
+
}
|
|
694
|
+
export function legalAggressiveSizes(engine, seat, family) {
|
|
695
|
+
const player = engine.state?.players?.[seat];
|
|
696
|
+
if (!player) throw new Error(`No player at seat ${seat}`);
|
|
697
|
+
const type = family === ACTION_FAMILY.RAISE ? ACTION.RAISE : ACTION.BET;
|
|
698
|
+
return planAggressiveSizes({
|
|
699
|
+
family,
|
|
700
|
+
descriptor: engineDecisionDescriptor(engine, seat),
|
|
701
|
+
validate: amount => Boolean(engine.validate({ type, playerId: player.id, amount })?.valid),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
export function sizeCriteria(sizes) { return Object.fromEntries((sizes ?? []).map(s => [s.id, s.label])); }
|
|
705
|
+
|
|
706
|
+
// The full canonical hierarchy for a state. This is the single representation
|
|
707
|
+
// used by production, diagnostics and tests.
|
|
708
|
+
export function buildHierarchicalDecision(state, { legalActions = null } = {}) {
|
|
709
|
+
const actions = legalActions ?? state?.legalActions ?? [];
|
|
710
|
+
const families = legalActionFamilies(actions, { toCall: state?.betting?.toCall ?? 0 });
|
|
711
|
+
const stage1 = { stage: 'family', index: 1, of: 2, families, criteria: familyCriteria(families), label: 'Action family' };
|
|
712
|
+
const aggressiveFamily = families.find(f => f === ACTION_FAMILY.BET || f === ACTION_FAMILY.RAISE) ?? null;
|
|
713
|
+
const sizes = aggressiveFamily ? aggressiveSizesForState(state, aggressiveFamily) : [];
|
|
714
|
+
const stage2 = aggressiveFamily
|
|
715
|
+
? { stage: 'size', index: 2, of: 2, family: aggressiveFamily, sizes, criteria: sizeCriteria(sizes), label: aggressiveFamily === ACTION_FAMILY.RAISE ? 'Raise size' : 'Bet size' }
|
|
716
|
+
: null;
|
|
717
|
+
return { architecture: DECISION_ARCHITECTURE_VERSION, families, stage1, aggressiveFamily, stage2 };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// LEGACY FLAT PROBABILITY AGGREGATION
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
// `labelResolver(key)` may return `{ type }` or `{ description }` for legacy
|
|
724
|
+
// events whose saved legal actions did not carry an action type.
|
|
725
|
+
function inferActionTypeFromDescription(description) {
|
|
726
|
+
const text = String(description || '').trim();
|
|
727
|
+
if (/^fold\b/i.test(text)) return ACTION.FOLD;
|
|
728
|
+
if (/^check\b/i.test(text)) return ACTION.CHECK;
|
|
729
|
+
if (/^call\b/i.test(text)) return ACTION.CALL;
|
|
730
|
+
if (/^bet\b/i.test(text)) return ACTION.BET;
|
|
731
|
+
if (/^raise\b/i.test(text)) return ACTION.RAISE;
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
export function aggregateActionProbabilitiesByFamily(probabilities, legalActions, { labelResolver = null } = {}) {
|
|
735
|
+
const out = {};
|
|
736
|
+
for (const [key, value] of Object.entries(probabilities ?? {})) {
|
|
737
|
+
const mass = Number(value);
|
|
738
|
+
if (!Number.isFinite(mass)) continue;
|
|
739
|
+
const action = (legalActions ?? []).find(a => a?.id === key) ?? null;
|
|
740
|
+
let type = action?.type ?? null;
|
|
741
|
+
if (!type && labelResolver) {
|
|
742
|
+
const resolved = labelResolver(key);
|
|
743
|
+
type = resolved?.type ?? inferActionTypeFromDescription(resolved?.description);
|
|
744
|
+
}
|
|
745
|
+
if (!type) type = inferActionTypeFromDescription(action?.description);
|
|
746
|
+
const family = type ? familyForActionType(type) : 'other';
|
|
747
|
+
out[family] = (out[family] ?? 0) + mass;
|
|
748
|
+
}
|
|
749
|
+
return out;
|
|
750
|
+
}
|
|
751
|
+
export function probabilityStats(probabilities, selectedKey = null, { domain = 'flat_action' } = {}) {
|
|
752
|
+
const entries = Object.entries(probabilities ?? {}).filter(([, v]) => Number.isFinite(Number(v)));
|
|
753
|
+
if (!entries.length) return { domain, selectedProbability: null, topProbability: null, secondProbability: null, topKey: null, secondKey: null, gap: null, entropy: null, entropyBits: null, totalMass: 0 };
|
|
754
|
+
const total = entries.reduce((s, [, v]) => s + Number(v), 0);
|
|
755
|
+
entries.sort((a, b) => Number(b[1]) - Number(a[1]));
|
|
756
|
+
const [topKey, topValue] = entries[0];
|
|
757
|
+
const [secondKey, secondValue] = entries[1] ?? [null, 0];
|
|
758
|
+
const selected = selectedKey != null ? entries.find(([k]) => k === selectedKey) : null;
|
|
759
|
+
let entropy = null;
|
|
760
|
+
if (total > 0) { entropy = 0; for (const [, v] of entries) { const p = Number(v) / total; if (p > 0) entropy -= p * Math.log2(p); } }
|
|
761
|
+
return {
|
|
762
|
+
domain,
|
|
763
|
+
selectedProbability: selected ? Number(selected[1]) : null,
|
|
764
|
+
topProbability: Number(topValue), secondProbability: entries[1] ? Number(secondValue) : null,
|
|
765
|
+
topKey, secondKey, gap: Number(topValue) - Number(secondValue),
|
|
766
|
+
// Named domains are mandatory in reports. `entropyBits` is exact; `entropy`
|
|
767
|
+
// is the rounded legacy alias kept for older saved diagnostics.
|
|
768
|
+
entropyBits: entropy,
|
|
769
|
+
entropy: entropy == null ? null : Math.round(entropy * 1000) / 1000,
|
|
770
|
+
totalMass: total,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Explicit, domain-labelled entropy helpers. Reports must call these rather
|
|
775
|
+
// than compare a generic "entropy" across different probability domains.
|
|
776
|
+
export function familyEntropyBits(probabilities, selectedKey = null) { return probabilityStats(probabilities, selectedKey, { domain: 'family' }).entropyBits; }
|
|
777
|
+
export function sizingEntropyBits(probabilities, selectedKey = null) { return probabilityStats(probabilities, selectedKey, { domain: 'sizing' }).entropyBits; }
|
|
778
|
+
export function flatActionEntropyBits(probabilities, selectedKey = null) { return probabilityStats(probabilities, selectedKey, { domain: 'flat_action' }).entropyBits; }
|
|
779
|
+
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
// Shared request builders. The diagnostic harness uses these so the exact
|
|
782
|
+
// production format is exercised by tests instead of a fork.
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
export function actionCriteria(legalActions, { keyMode = 'opaque' } = {}) {
|
|
785
|
+
if (keyMode === 'opaque') return Object.fromEntries(legalActions.map(a => [a.id, a.description]));
|
|
786
|
+
// Semantic keys are readable identifiers derived from the engine action, never
|
|
787
|
+
// from free text, and always resolve back to exactly one legal action.
|
|
788
|
+
const used = new Set();
|
|
789
|
+
const entries = legalActions.map(a => {
|
|
790
|
+
const base = [];
|
|
791
|
+
base.push(String(a.type || 'action').toLowerCase());
|
|
792
|
+
if (a.amount !== null && a.amount !== undefined && Number.isFinite(Number(a.amount))) base.push(String(Math.round(Number(a.amount))));
|
|
793
|
+
let key = base.join('_');
|
|
794
|
+
if (used.has(key)) { let i = 2; while (used.has(`${key}_${i}`)) i++; key = `${key}_${i}`; }
|
|
795
|
+
used.add(key);
|
|
796
|
+
return [key, a.description, a];
|
|
797
|
+
});
|
|
798
|
+
const criteria = {};
|
|
799
|
+
const mapping = {};
|
|
800
|
+
for (const [key, description, action] of entries) { criteria[key] = description; mapping[key] = action; }
|
|
801
|
+
Object.defineProperty(criteria, '__mapping', { value: mapping, enumerable: false });
|
|
802
|
+
return criteria;
|
|
803
|
+
}
|
|
804
|
+
export function resolveCriteriaKey(legalActions, key) {
|
|
805
|
+
const byId = legalActions.find(a => a.id === key);
|
|
806
|
+
if (byId) return byId;
|
|
807
|
+
const semantic = actionCriteria(legalActions, { keyMode: 'semantic' });
|
|
808
|
+
return semantic.__mapping?.[key] ?? null;
|
|
809
|
+
}
|
|
810
|
+
export function buildJevQuestions({ instructions = DECISION_OBJECTIVE, criteria, includeAction = true, includeAggression = false, includeBluff = false } = {}) {
|
|
811
|
+
const questions = {};
|
|
812
|
+
if (includeAction) questions.action = { type: 'choice', instructions, criteria };
|
|
813
|
+
if (includeAggression) questions.aggression = { type: 'score', instructions: 'How aggressive should the hero strategy be in this spot?', criteria: ['Very passive', 'Cautious', 'Balanced', 'Aggressive', 'Maximum pressure'] };
|
|
814
|
+
if (includeBluff) questions.bluff_spot = { type: 'noul', instructions: 'Is this a strategically plausible spot to apply aggression primarily as a bluff or semi-bluff?' };
|
|
815
|
+
return questions;
|
|
816
|
+
}
|
|
817
|
+
export function buildJevDecisionsBody({ model, state, questions }) {
|
|
818
|
+
return { model, state, questions };
|
|
819
|
+
}
|
|
820
|
+
export function buildJevNativeBody({ model, state, questions }) {
|
|
821
|
+
return { model, state: typeof state === 'string' ? state : JSON.stringify(state), questions };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function executeJevRequest({ url, connection, body, signal, started, agent, legalActions, resolveKey }) {
|
|
825
|
+
const { payload, incidents, retryCount } = await fetchJsonWithRetry(url, { method: 'POST', headers: makeHeaders(connection), body: JSON.stringify(body), signal }, { maxRetries: 1 });
|
|
826
|
+
const answer = payload?.answers?.action;
|
|
827
|
+
const key = answer?.choice;
|
|
828
|
+
const action = resolveKey(key, legalActions);
|
|
829
|
+
if (!action) throw new Error(`Jev selected unknown action: ${key}`);
|
|
830
|
+
const probs = answer?.probabilities ?? {};
|
|
831
|
+
const ordered = Object.entries(probs).sort((a, b) => Number(b[1]) - Number(a[1])).slice(0, 4);
|
|
832
|
+
const probText = ordered.map(([aid, p]) => {
|
|
833
|
+
const label = legalActions.find(a => a.id === aid)?.description || aid;
|
|
834
|
+
return `${label} ${Math.round(Number(p) * 100)}%`;
|
|
835
|
+
}).join(', ');
|
|
836
|
+
const aggression = payload?.answers?.aggression?.score;
|
|
837
|
+
const bluff = payload?.answers?.bluff_spot?.noul;
|
|
838
|
+
const extras = [Number.isFinite(aggression) ? `aggression ${round(aggression, 2)}/4` : null, Number.isFinite(bluff) ? `bluff spot ${Math.round(bluff * 100)}%` : null].filter(Boolean).join(', ');
|
|
839
|
+
return {
|
|
840
|
+
action, publicReason: `${probText}${extras ? ` · ${extras}` : ''}`.slice(0, 220), latencyMs: Math.round(performance.now() - started),
|
|
841
|
+
model: payload?.model || agent.model, usage: payload?.usage ?? null,
|
|
842
|
+
meta: { method: 'openrouter-decisions', endpoint: url, probabilities: probs, confidence: answer?.confidence ?? null, aggression: aggression ?? null, bluffSpot: bluff ?? null, retryCount, incidents },
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export async function decideJevDecisions({ agent, connection, state, legalActions, timeoutMs, abortSignal, pauseClock, criteriaKeys = 'opaque', includeAggression = false, includeBluff = false }) {
|
|
847
|
+
const { signal, cancel } = combineAbort(timeoutMs, abortSignal, pauseClock);
|
|
848
|
+
const started = performance.now();
|
|
849
|
+
const criteria = actionCriteria(legalActions, { keyMode: criteriaKeys });
|
|
850
|
+
const questions = buildJevQuestions({ criteria, includeAction: true, includeAggression, includeBluff });
|
|
851
|
+
const body = buildJevDecisionsBody({ model: agent.model || '~typesafe/jev-latest', state, questions });
|
|
852
|
+
try {
|
|
853
|
+
return await executeJevRequest({
|
|
854
|
+
url: openRouterDecisionsUrl(connection.baseUrl), connection, body, signal, started, agent, legalActions,
|
|
855
|
+
resolveKey: (key, actions) => actions.find(a => a.id === key) || resolveCriteriaKey(actions, key),
|
|
856
|
+
});
|
|
857
|
+
} finally { cancel(); }
|
|
858
|
+
}
|
|
859
|
+
export async function decideJevNative({ agent, connection, state, legalActions, timeoutMs, abortSignal, pauseClock, criteriaKeys = 'opaque', includeAggression = false, includeBluff = false }) {
|
|
860
|
+
const { signal, cancel } = combineAbort(timeoutMs, abortSignal, pauseClock);
|
|
861
|
+
const started = performance.now();
|
|
862
|
+
const criteria = actionCriteria(legalActions, { keyMode: criteriaKeys });
|
|
863
|
+
const questions = buildJevQuestions({ criteria, includeAction: true, includeAggression, includeBluff });
|
|
864
|
+
const body = buildJevNativeBody({ model: agent.model || 'jev-latest', state, questions });
|
|
865
|
+
try {
|
|
866
|
+
return await executeJevRequest({
|
|
867
|
+
url: normalizeBaseUrl(connection.baseUrl), connection, body, signal, started, agent, legalActions,
|
|
868
|
+
resolveKey: (key, actions) => actions.find(a => a.id === key) || resolveCriteriaKey(actions, key),
|
|
869
|
+
});
|
|
870
|
+
} finally { cancel(); }
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
export function buildOpenAICompatibleBody({ agent, connection, state, legalActions, decisionId, protocol = 'tool', instructions = null, criteria = null, representationText = null }) {
|
|
874
|
+
const isOpenRouter = isOpenRouterConnection(connection);
|
|
875
|
+
const criteriaKeys = criteria ? Object.keys(criteria) : null;
|
|
876
|
+
const useChoice = Boolean(criteriaKeys?.length);
|
|
877
|
+
const schema = useChoice
|
|
878
|
+
? { type: 'object', properties: { actionId: { type: 'string', enum: criteriaKeys }, publicReason: { type: 'string', maxLength: 220 } }, required: ['actionId'], additionalProperties: false }
|
|
879
|
+
: {
|
|
880
|
+
type: 'object', properties: {
|
|
881
|
+
decisionId: { type: 'string', enum: [decisionId] },
|
|
882
|
+
actionId: { type: 'string', enum: legalActions.map(a => a.id) },
|
|
883
|
+
publicReason: { type: 'string', maxLength: 220 },
|
|
884
|
+
}, required: ['decisionId', 'actionId', 'publicReason'], additionalProperties: false,
|
|
885
|
+
};
|
|
886
|
+
const userContent = representationText
|
|
887
|
+
? `${instructions || 'Choose exactly one action.'}\n\n${representationText}\n\nRespond with one of: ${criteriaKeys.map(k => `${k} = ${criteria[k]}`).join('; ')}.`
|
|
888
|
+
: pokerPrompt(state);
|
|
889
|
+
const body = {
|
|
890
|
+
model: agent.model,
|
|
891
|
+
messages: [
|
|
892
|
+
{ role: 'system', content: 'You are one seat in an autonomous poker benchmark. Commit exactly one legal move.' },
|
|
893
|
+
{ role: 'user', content: userContent },
|
|
894
|
+
],
|
|
895
|
+
temperature: Number.isFinite(Number(agent.temperature)) ? Number(agent.temperature) : 0.3,
|
|
896
|
+
max_tokens: isReasoningModel(agent.model) ? 1024 : 320,
|
|
897
|
+
};
|
|
898
|
+
if (protocol === 'tool') {
|
|
899
|
+
body.tools = [{ type: 'function', function: { name: 'play_poker_action', description: 'Commit exactly one legal poker action for the current decision.', parameters: schema } }];
|
|
900
|
+
body.tool_choice = { type: 'function', function: { name: 'play_poker_action' } };
|
|
901
|
+
} else if (protocol === 'json_schema') {
|
|
902
|
+
body.response_format = { type: 'json_schema', json_schema: { name: 'poker_decision', strict: true, schema } };
|
|
903
|
+
body.messages[0].content += ' Return the decision in the required JSON schema.';
|
|
904
|
+
} else {
|
|
905
|
+
body.messages[0].content += ` Return only JSON matching: ${JSON.stringify(schema)}`;
|
|
906
|
+
}
|
|
907
|
+
if (isOpenRouter) {
|
|
908
|
+
body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
|
|
909
|
+
if (isReasoningModel(agent.model)) body.reasoning = { max_tokens: 256, exclude: true };
|
|
910
|
+
}
|
|
911
|
+
return body;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export async function decideOpenAICompatible({ agent, connection, state, legalActions, decisionId, timeoutMs, abortSignal, pauseClock, protocol: protocolOverride = null, instructions = null, criteria = null, representationText = null }) {
|
|
915
|
+
const { signal, cancel } = combineAbort(timeoutMs, abortSignal, pauseClock);
|
|
916
|
+
const started = performance.now();
|
|
917
|
+
const requestedProtocol = protocolOverride || agent.protocol;
|
|
918
|
+
const capabilityKey = `${normalizeBaseUrl(connection.baseUrl)}|${agent.model}|${agent.provider || 'auto'}|tool`;
|
|
919
|
+
const isOpenRouter = isOpenRouterConnection(connection);
|
|
920
|
+
const cachedProtocol = requestedProtocol === 'tool' && isOpenRouter && !criteria ? protocolCapabilityCache.get(capabilityKey) : null;
|
|
921
|
+
const buildBody = protocol => buildOpenAICompatibleBody({ agent, connection, state, legalActions, decisionId, protocol, instructions, criteria, representationText });
|
|
922
|
+
|
|
923
|
+
async function execute(protocol) {
|
|
924
|
+
const body = buildBody(protocol);
|
|
925
|
+
const { payload, incidents, retryCount } = await fetchJsonWithRetry(completionsUrl(connection.baseUrl), {
|
|
926
|
+
method: 'POST', headers: makeHeaders(connection), body: JSON.stringify(body), signal,
|
|
927
|
+
}, { maxRetries: 1 });
|
|
928
|
+
const message = payload?.choices?.[0]?.message;
|
|
929
|
+
let obj;
|
|
930
|
+
if (protocol === 'tool') {
|
|
931
|
+
const call = message?.tool_calls?.find(item => item?.function?.name === 'play_poker_action');
|
|
932
|
+
if (!call) throw new Error('Model did not call play_poker_action');
|
|
933
|
+
try { obj = JSON.parse(call.function.arguments); } catch { throw new Error('Tool arguments were not valid JSON'); }
|
|
934
|
+
} else {
|
|
935
|
+
const content = stripCodeFence(extractTextContent(message));
|
|
936
|
+
try { obj = JSON.parse(content); } catch { throw new Error(`Model response was not valid JSON: ${content.slice(0, 180)}`); }
|
|
937
|
+
}
|
|
938
|
+
return { obj, payload, incidents, retryCount, protocol };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
try {
|
|
942
|
+
let actualProtocol = cachedProtocol || requestedProtocol;
|
|
943
|
+
let protocolFallback = cachedProtocol === 'json_schema' ? 'tool→json_schema (cached)' : null;
|
|
944
|
+
let protocolFallbackTriggered = false;
|
|
945
|
+
let result;
|
|
946
|
+
try {
|
|
947
|
+
result = await execute(actualProtocol);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
if (actualProtocol === 'tool' && requestedProtocol === 'tool' && isOpenRouter && isUnsupportedToolChoiceError(err)) {
|
|
950
|
+
protocolCapabilityCache.set(capabilityKey, 'json_schema');
|
|
951
|
+
actualProtocol = 'json_schema';
|
|
952
|
+
protocolFallback = 'tool→json_schema';
|
|
953
|
+
protocolFallbackTriggered = true;
|
|
954
|
+
result = await execute(actualProtocol);
|
|
955
|
+
} else throw err;
|
|
956
|
+
}
|
|
957
|
+
const normalized = normalizeDecisionObject(result.obj, legalActions, decisionId);
|
|
958
|
+
return {
|
|
959
|
+
...normalized,
|
|
960
|
+
latencyMs: Math.round(performance.now() - started),
|
|
961
|
+
model: result.payload?.model || agent.model,
|
|
962
|
+
usage: result.payload?.usage ?? null,
|
|
963
|
+
meta: {
|
|
964
|
+
method: result.protocol,
|
|
965
|
+
requestedMethod: requestedProtocol,
|
|
966
|
+
endpoint: connection.name,
|
|
967
|
+
protocolFallback,
|
|
968
|
+
protocolFallbackTriggered,
|
|
969
|
+
retryCount: result.retryCount,
|
|
970
|
+
incidents: result.incidents,
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
} finally { cancel(); }
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export function decide(agent, connection, args) {
|
|
977
|
+
if (!connection) return Promise.reject(new Error(`Connection not found: ${agent.connectionId}`));
|
|
978
|
+
const protocol = effectiveProtocol(agent, connection);
|
|
979
|
+
if (protocol === 'jev_decisions') {
|
|
980
|
+
if (!isOpenRouterConnection(connection)) return Promise.reject(new Error('Jev Decisions requires an OpenRouter connection'));
|
|
981
|
+
// Production keeps the production auxiliary questions unless the caller
|
|
982
|
+
// explicitly overrides them (the diagnostics harness does).
|
|
983
|
+
return decideJevDecisions({ includeAggression: true, includeBluff: true, agent: { ...agent, protocol }, connection, ...args });
|
|
984
|
+
}
|
|
985
|
+
if (protocol === 'jev_native') {
|
|
986
|
+
if (connection.kind !== 'typesafe') return Promise.reject(new Error('Jev native requires a TypeSafe connection'));
|
|
987
|
+
return decideJevNative({ includeAggression: true, includeBluff: true, agent: { ...agent, protocol }, connection, ...args });
|
|
988
|
+
}
|
|
989
|
+
if (!['openai', 'openrouter'].includes(connection.kind)) return Promise.reject(new Error(`${protocol} requires an OpenAI-compatible connection`));
|
|
990
|
+
return decideOpenAICompatible({ agent: { ...agent, protocol }, connection, ...args, protocol });
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ===========================================================================
|
|
994
|
+
// HIERARCHICAL DECISION ARCHITECTURE (hierarchical-v1)
|
|
995
|
+
//
|
|
996
|
+
// Stage 1 asks for an action family; stage 2 asks for a size from exactly the
|
|
997
|
+
// same deterministic set that every other model receives. The primary decision
|
|
998
|
+
// contract contains only typed decision fields — never prose. Both stages share
|
|
999
|
+
// one action clock.
|
|
1000
|
+
// ===========================================================================
|
|
1001
|
+
export const FAMILY_OBJECTIVE = 'Choose exactly one legal action family that best maximizes tournament chip EV from the supplied state. Do not choose an amount; a size is chosen in a separate step.';
|
|
1002
|
+
export const SIZE_OBJECTIVE = family => `The action family ${String(family).toUpperCase()} has already been selected. Choose exactly one legal ${family === ACTION_FAMILY.RAISE ? 'raise size' : 'bet size'} that best maximizes tournament chip EV. Do not change the action family.`;
|
|
1003
|
+
export const SPECTATOR_NOTE = 'This model returns typed decisions rather than a text rationale.';
|
|
1004
|
+
|
|
1005
|
+
export function buildJevFamilyQuestions(families, { instructions = FAMILY_OBJECTIVE, criteria = null } = {}) {
|
|
1006
|
+
return { type: 'choice', instructions, criteria: criteria ?? familyCriteria(families) };
|
|
1007
|
+
}
|
|
1008
|
+
export function buildJevSizeQuestions(family, sizes, { instructions = null, criteria = null } = {}) {
|
|
1009
|
+
return { type: 'choice', instructions: instructions ?? SIZE_OBJECTIVE(family), criteria: criteria ?? sizeCriteria(sizes) };
|
|
1010
|
+
}
|
|
1011
|
+
export function chatFamilySchema(families) {
|
|
1012
|
+
return { type: 'object', properties: { decisionId: { type: 'string' }, actionFamily: { type: 'string', enum: [...families] } }, required: ['decisionId', 'actionFamily'], additionalProperties: false };
|
|
1013
|
+
}
|
|
1014
|
+
export function chatSizeSchema(sizes) {
|
|
1015
|
+
return { type: 'object', properties: { decisionId: { type: 'string' }, sizeId: { type: 'string', enum: sizes.map(s => s.id) } }, required: ['decisionId', 'sizeId'], additionalProperties: false };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Canonical representation. Both transports receive a semantically identical
|
|
1019
|
+
// state; only the serialization differs. This is the single place where the
|
|
1020
|
+
// representation mode is interpreted.
|
|
1021
|
+
export function renderDecisionState(state, mode = DEFAULT_REPRESENTATION_MODE) {
|
|
1022
|
+
if (mode === 'markdown') {
|
|
1023
|
+
const lines = [
|
|
1024
|
+
'# Poker decision',
|
|
1025
|
+
`- Hero: ${(state?.hero?.cards ?? state?.heroCards ?? []).join(' ') || '—'}`,
|
|
1026
|
+
`- Board: ${(state?.board ?? []).join(' ') || '—'}`,
|
|
1027
|
+
`- Street: ${state?.street ?? '—'}`,
|
|
1028
|
+
`- Pot: ${state?.pot ?? 0}`,
|
|
1029
|
+
`- To call: ${state?.betting?.toCall ?? 0}`,
|
|
1030
|
+
`- Hero stack: ${state?.hero?.stack ?? 0}`,
|
|
1031
|
+
];
|
|
1032
|
+
if (state?.heroHand?.category) lines.push(`- Deterministic hero hand: ${state.heroHand.category}`);
|
|
1033
|
+
if (Array.isArray(state?.legalActions) && state.legalActions.length) lines.push('', '## Legal actions', ...state.legalActions.map(a => `- ${a.id}: ${a.description ?? a.type}`));
|
|
1034
|
+
const text = lines.join('\n');
|
|
1035
|
+
return { jevState: text, chatText: text, mode };
|
|
1036
|
+
}
|
|
1037
|
+
if (mode === 'compact_json') {
|
|
1038
|
+
const compact = {
|
|
1039
|
+
game: state?.game ?? 'No-Limit Texas Holdem tournament',
|
|
1040
|
+
street: state?.street, hero: state?.hero, heroHand: state?.heroHand ?? undefined,
|
|
1041
|
+
board: state?.board, pot: state?.pot, blinds: state?.blinds, betting: state?.betting,
|
|
1042
|
+
opponents: state?.opponents, legalActions: state?.legalActions,
|
|
1043
|
+
};
|
|
1044
|
+
const json = JSON.stringify(compact);
|
|
1045
|
+
return { jevState: compact, chatText: json, mode };
|
|
1046
|
+
}
|
|
1047
|
+
return { jevState: state, chatText: JSON.stringify(state), mode: 'canonical_json' };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// A model may only return a family that was actually offered by the arena.
|
|
1051
|
+
export function normalizeFamilyChoice(obj, families, decisionId = null) {
|
|
1052
|
+
if (!obj || typeof obj !== 'object') throw new Error('Model returned no action-family object');
|
|
1053
|
+
if (obj.decisionId && decisionId && obj.decisionId !== decisionId) throw new Error('Stale/wrong decisionId');
|
|
1054
|
+
const choice = normalizeChoiceKey(obj.actionFamily ?? obj.answer);
|
|
1055
|
+
if (!families.includes(choice)) throw new Error(`Unknown action family: ${choice}`);
|
|
1056
|
+
return choice;
|
|
1057
|
+
}
|
|
1058
|
+
export function normalizeSizeChoice(obj, sizes, decisionId = null) {
|
|
1059
|
+
if (!obj || typeof obj !== 'object') throw new Error('Model returned no size object');
|
|
1060
|
+
if (obj.decisionId && decisionId && obj.decisionId !== decisionId) throw new Error('Stale/wrong decisionId');
|
|
1061
|
+
const choice = normalizeChoiceKey(obj.sizeId ?? obj.answer);
|
|
1062
|
+
if (!sizes.some(s => s.id === choice)) throw new Error(`Unknown size id: ${choice}`);
|
|
1063
|
+
return choice;
|
|
1064
|
+
}
|
|
1065
|
+
function normalizeChoiceKey(value) { return String(value ?? '').trim().toLowerCase().replace(/-/g, '_'); }
|
|
1066
|
+
|
|
1067
|
+
// In hierarchical mode the model must not receive a flat action/size menu; the
|
|
1068
|
+
// family question and (only when needed) the size question are the choice sets.
|
|
1069
|
+
function withoutFlatMenu(value) {
|
|
1070
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
1071
|
+
const { legalActions, ...rest } = value;
|
|
1072
|
+
return rest;
|
|
1073
|
+
}
|
|
1074
|
+
function withoutFlatMenuText(text) {
|
|
1075
|
+
if (typeof text !== 'string') return text;
|
|
1076
|
+
try {
|
|
1077
|
+
const parsed = JSON.parse(text);
|
|
1078
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return JSON.stringify(withoutFlatMenu(parsed));
|
|
1079
|
+
} catch {}
|
|
1080
|
+
return text;
|
|
1081
|
+
}
|
|
1082
|
+
// Resolve a (family, optional size) pair to exactly one legal action.
|
|
1083
|
+
export function resolveHierarchicalAction({ legalActions, family, size }) {
|
|
1084
|
+
const type = actionTypeForFamily(family);
|
|
1085
|
+
if (!type) return null;
|
|
1086
|
+
if (family === ACTION_FAMILY.BET || family === ACTION_FAMILY.RAISE) {
|
|
1087
|
+
if (!size) return null;
|
|
1088
|
+
const exact = (legalActions ?? []).find(a => a.type === type && Number(a.amount) === Number(size.amount));
|
|
1089
|
+
// The deterministic size is authoritative. When the flat candidate list does
|
|
1090
|
+
// not contain that exact amount (it usually will not in production), keep the
|
|
1091
|
+
// validated size amount and use the candidate only as a display reference.
|
|
1092
|
+
const reference = exact ?? [...(legalActions ?? [])].filter(a => a.type === type)
|
|
1093
|
+
.sort((a, b) => Math.abs(Number(a.amount) - Number(size.amount)) - Math.abs(Number(b.amount) - Number(size.amount)))[0] ?? null;
|
|
1094
|
+
return { id: reference?.id ?? null, type, amount: size.amount, description: size.label, family, sizeId: size.id };
|
|
1095
|
+
}
|
|
1096
|
+
const action = (legalActions ?? []).find(a => a.type === type);
|
|
1097
|
+
return action ? { ...action, family, sizeId: null } : { id: null, type, amount: null, description: FAMILY_LABELS[family] ?? family, family, sizeId: null };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async function requestJevStage({ connection, model, state, questionKey, question, signal }) {
|
|
1101
|
+
const native = connection.kind === 'typesafe';
|
|
1102
|
+
const url = native ? normalizeBaseUrl(connection.baseUrl) : openRouterDecisionsUrl(connection.baseUrl);
|
|
1103
|
+
const body = native
|
|
1104
|
+
? { model, state: typeof state === 'string' ? state : JSON.stringify(state), questions: { [questionKey]: question } }
|
|
1105
|
+
: { model, state, questions: { [questionKey]: question } };
|
|
1106
|
+
const started = performance.now();
|
|
1107
|
+
const { payload, incidents, retryCount } = await fetchJsonWithRetry(url, { method: 'POST', headers: makeHeaders(connection), body: JSON.stringify(body), signal }, { maxRetries: 1 });
|
|
1108
|
+
const answer = payload?.answers?.[questionKey];
|
|
1109
|
+
if (!answer?.choice) throw new Error(`Jev response had no answers.${questionKey}.choice`);
|
|
1110
|
+
const ranked = Object.entries(answer.probabilities ?? {}).filter(([, v]) => Number.isFinite(Number(v))).sort((a, b) => Number(b[1]) - Number(a[1]));
|
|
1111
|
+
return {
|
|
1112
|
+
choice: answer.choice, probabilities: answer.probabilities ?? null, confidence: answer.confidence ?? null,
|
|
1113
|
+
latencyMs: Math.round(performance.now() - started), model: payload?.model || model, usage: payload?.usage ?? null,
|
|
1114
|
+
incidents, retryCount, top: ranked[0]?.[0] ?? null, topProbability: ranked[0]?.[1] ?? null,
|
|
1115
|
+
second: ranked[1]?.[0] ?? null, secondProbability: ranked[1]?.[1] ?? null,
|
|
1116
|
+
gap: ranked.length >= 2 ? Number(ranked[0][1]) - Number(ranked[1][1]) : null,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async function requestChatStage({ agent, connection, schema, toolName, systemPrompt, userPrompt, signal, requestedProtocol, temperature }) {
|
|
1121
|
+
const isOpenRouter = isOpenRouterConnection(connection);
|
|
1122
|
+
const buildBody = protocol => {
|
|
1123
|
+
const body = {
|
|
1124
|
+
model: agent.model,
|
|
1125
|
+
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }],
|
|
1126
|
+
temperature: Number.isFinite(Number(temperature)) ? Number(temperature) : 0.3,
|
|
1127
|
+
max_tokens: isReasoningModel(agent.model) ? 1024 : 320,
|
|
1128
|
+
};
|
|
1129
|
+
if (protocol === 'tool') {
|
|
1130
|
+
body.tools = [{ type: 'function', function: { name: toolName, description: `Submit the ${toolName.replaceAll('_', ' ')}.`, parameters: schema } }];
|
|
1131
|
+
body.tool_choice = { type: 'function', function: { name: toolName } };
|
|
1132
|
+
} else if (protocol === 'json_schema') {
|
|
1133
|
+
body.response_format = { type: 'json_schema', json_schema: { name: toolName, strict: true, schema } };
|
|
1134
|
+
body.messages[0].content += ' Return the decision in the required JSON schema.';
|
|
1135
|
+
} else {
|
|
1136
|
+
body.messages[0].content += ` Return only JSON matching: ${JSON.stringify(schema)}`;
|
|
1137
|
+
}
|
|
1138
|
+
if (isOpenRouter) {
|
|
1139
|
+
body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
|
|
1140
|
+
if (isReasoningModel(agent.model)) body.reasoning = { max_tokens: 256, exclude: true };
|
|
1141
|
+
}
|
|
1142
|
+
return body;
|
|
1143
|
+
};
|
|
1144
|
+
const capabilityKey = `${normalizeBaseUrl(connection.baseUrl)}|${agent.model}|${agent.provider || 'auto'}|${toolName}`;
|
|
1145
|
+
const cachedProtocol = requestedProtocol === 'tool' && isOpenRouter ? protocolCapabilityCache.get(capabilityKey) : null;
|
|
1146
|
+
const execute = async protocol => {
|
|
1147
|
+
const started = performance.now();
|
|
1148
|
+
const { payload, incidents, retryCount } = await fetchJsonWithRetry(completionsUrl(connection.baseUrl), {
|
|
1149
|
+
method: 'POST', headers: makeHeaders(connection), body: JSON.stringify(buildBody(protocol)), signal,
|
|
1150
|
+
}, { maxRetries: 1 });
|
|
1151
|
+
const message = payload?.choices?.[0]?.message;
|
|
1152
|
+
let obj;
|
|
1153
|
+
if (protocol === 'tool') {
|
|
1154
|
+
const call = message?.tool_calls?.find(item => item?.function?.name === toolName);
|
|
1155
|
+
if (!call) throw new Error(`Model did not call ${toolName}`);
|
|
1156
|
+
try { obj = JSON.parse(call.function.arguments); } catch { throw new Error('Tool arguments were not valid JSON'); }
|
|
1157
|
+
} else {
|
|
1158
|
+
obj = JSON.parse(stripCodeFence(extractTextContent(message)));
|
|
1159
|
+
}
|
|
1160
|
+
return { obj, protocol, latencyMs: Math.round(performance.now() - started), model: payload?.model || agent.model, usage: payload?.usage ?? null, incidents, retryCount };
|
|
1161
|
+
};
|
|
1162
|
+
let actual = cachedProtocol || requestedProtocol;
|
|
1163
|
+
let protocolFallback = cachedProtocol ? 'cached json_schema' : null;
|
|
1164
|
+
try {
|
|
1165
|
+
return await execute(actual);
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
if (actual === 'tool' && requestedProtocol === 'tool' && isOpenRouter && isUnsupportedToolChoiceError(err)) {
|
|
1168
|
+
protocolCapabilityCache.set(capabilityKey, 'json_schema');
|
|
1169
|
+
const result = await execute('json_schema');
|
|
1170
|
+
return { ...result, protocolFallback: 'tool→json_schema' };
|
|
1171
|
+
}
|
|
1172
|
+
throw err;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// The one hierarchical entry point used by production and diagnostics.
|
|
1177
|
+
export async function decideHierarchical({
|
|
1178
|
+
agent, connection, state, legalActions, decisionId, timeoutMs, abortSignal, pauseClock,
|
|
1179
|
+
representationText = null, jevState = null, representationMode = DEFAULT_REPRESENTATION_MODE, protocol: protocolOverride = null,
|
|
1180
|
+
sizesForFamily = null, onStage = null,
|
|
1181
|
+
}) {
|
|
1182
|
+
if (!connection) throw new Error(`Connection not found: ${agent.connectionId}`);
|
|
1183
|
+
const protocol = protocolOverride || effectiveProtocol(agent, connection);
|
|
1184
|
+
const isJev = protocol === 'jev_decisions' || protocol === 'jev_native';
|
|
1185
|
+
const families = legalActionFamilies(legalActions, { toCall: state?.betting?.toCall ?? 0 });
|
|
1186
|
+
if (!families.length) throw new Error('No legal action families for this decision');
|
|
1187
|
+
const sizesOf = family => (sizesForFamily ? sizesForFamily(family) : aggressiveSizesForState(state, family));
|
|
1188
|
+
const renderState = withoutFlatMenu(state);
|
|
1189
|
+
const rendered = (representationText != null || jevState != null)
|
|
1190
|
+
? { jevState: jevState != null ? withoutFlatMenu(jevState) : renderState, chatText: representationText != null ? withoutFlatMenuText(representationText) : JSON.stringify(renderState) }
|
|
1191
|
+
: renderDecisionState(renderState, representationMode);
|
|
1192
|
+
const { signal, cancel } = combineAbort(timeoutMs, abortSignal, pauseClock);
|
|
1193
|
+
const startedTotal = performance.now();
|
|
1194
|
+
const usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
1195
|
+
const addUsage = u => { for (const key of ['prompt_tokens', 'completion_tokens', 'total_tokens', 'input_tokens', 'output_tokens']) if (Number.isFinite(Number(u?.[key]))) usage[key] += Number(u[key]); };
|
|
1196
|
+
try {
|
|
1197
|
+
const familyStage = { families, criteria: familyCriteria(families) };
|
|
1198
|
+
if (onStage) onStage({ stage: 'family', index: 1, of: 2, families, criteria: familyStage.criteria });
|
|
1199
|
+
let familyResult;
|
|
1200
|
+
if (isJev) {
|
|
1201
|
+
familyResult = await requestJevStage({
|
|
1202
|
+
connection, model: agent.model, state: rendered.jevState,
|
|
1203
|
+
questionKey: 'action_family', question: buildJevFamilyQuestions(families), signal,
|
|
1204
|
+
});
|
|
1205
|
+
familyResult.choice = normalizeChoiceKey(familyResult.choice);
|
|
1206
|
+
} else {
|
|
1207
|
+
const schema = chatFamilySchema(families);
|
|
1208
|
+
const userPrompt = [
|
|
1209
|
+
FAMILY_OBJECTIVE, '', rendered.chatText, '',
|
|
1210
|
+
`Allowed action families: ${families.map(f => `${f} = ${FAMILY_LABELS[f]}`).join('; ')}.`,
|
|
1211
|
+
`Respond with { "decisionId": ${JSON.stringify(decisionId)}, "actionFamily": "<family>" }.`,
|
|
1212
|
+
].join('\n');
|
|
1213
|
+
const result = await requestChatStage({
|
|
1214
|
+
agent, connection, schema, toolName: 'choose_action_family',
|
|
1215
|
+
systemPrompt: 'You are one seat in an autonomous poker benchmark. Choose exactly one legal action family.',
|
|
1216
|
+
userPrompt, signal, requestedProtocol: protocol, temperature: agent.temperature,
|
|
1217
|
+
});
|
|
1218
|
+
familyResult = { ...result, choice: normalizeFamilyChoice(result.obj, families, decisionId) };
|
|
1219
|
+
}
|
|
1220
|
+
addUsage(familyResult.usage);
|
|
1221
|
+
if (!families.includes(familyResult.choice)) throw new Error(`Model selected an illegal action family: ${familyResult.choice}`);
|
|
1222
|
+
const familyProbabilities = isJev ? familyResult.probabilities : null;
|
|
1223
|
+
|
|
1224
|
+
let sizingResult = null;
|
|
1225
|
+
let sizeStage = null;
|
|
1226
|
+
if (familyResult.choice === ACTION_FAMILY.BET || familyResult.choice === ACTION_FAMILY.RAISE) {
|
|
1227
|
+
const sizes = sizesOf(familyResult.choice);
|
|
1228
|
+
if (!sizes.length) throw new Error(`No legal ${familyResult.choice} sizes for this decision`);
|
|
1229
|
+
sizeStage = { family: familyResult.choice, sizes, criteria: sizeCriteria(sizes) };
|
|
1230
|
+
if (onStage) onStage({ stage: 'size', index: 2, of: 2, family: familyResult.choice, families: [familyResult.choice], sizes, criteria: sizeStage.criteria });
|
|
1231
|
+
if (isJev) {
|
|
1232
|
+
sizingResult = await requestJevStage({
|
|
1233
|
+
connection, model: agent.model, state: rendered.jevState,
|
|
1234
|
+
questionKey: 'bet_size', question: buildJevSizeQuestions(familyResult.choice, sizes), signal,
|
|
1235
|
+
});
|
|
1236
|
+
sizingResult.choice = normalizeChoiceKey(sizingResult.choice);
|
|
1237
|
+
if (!sizes.some(s => s.id === sizingResult.choice)) throw new Error(`Model selected an illegal size: ${sizingResult.choice}`);
|
|
1238
|
+
} else {
|
|
1239
|
+
const schema = chatSizeSchema(sizes);
|
|
1240
|
+
const userPrompt = [
|
|
1241
|
+
SIZE_OBJECTIVE(familyResult.choice), '', rendered.chatText, '',
|
|
1242
|
+
`Selected action family: ${familyResult.choice}.`,
|
|
1243
|
+
`Allowed sizes: ${sizes.map(s => `${s.id} = ${s.label}`).join('; ')}.`,
|
|
1244
|
+
`Respond with { "decisionId": ${JSON.stringify(decisionId)}, "sizeId": "<size>" }.`,
|
|
1245
|
+
].join('\n');
|
|
1246
|
+
const result = await requestChatStage({
|
|
1247
|
+
agent, connection, schema, toolName: 'choose_bet_size',
|
|
1248
|
+
systemPrompt: `You are one seat in an autonomous poker benchmark. The action family ${familyResult.choice.toUpperCase()} is fixed. Choose exactly one legal size.`,
|
|
1249
|
+
userPrompt, signal, requestedProtocol: protocol, temperature: agent.temperature,
|
|
1250
|
+
});
|
|
1251
|
+
sizingResult = { ...result, choice: normalizeSizeChoice(result.obj, sizes, decisionId) };
|
|
1252
|
+
}
|
|
1253
|
+
addUsage(sizingResult.usage);
|
|
1254
|
+
}
|
|
1255
|
+
const chosenSize = sizingResult ? (sizeStage.sizes.find(s => s.id === sizingResult.choice) ?? null) : null;
|
|
1256
|
+
const finalAction = resolveHierarchicalAction({ legalActions, family: familyResult.choice, size: chosenSize });
|
|
1257
|
+
if (!finalAction?.type) throw new Error('Hierarchical decision did not resolve to a legal action');
|
|
1258
|
+
const primaryDecisionLatencyMs = Math.round(performance.now() - startedTotal);
|
|
1259
|
+
return {
|
|
1260
|
+
action: finalAction,
|
|
1261
|
+
family: {
|
|
1262
|
+
choice: familyResult.choice, probabilities: familyProbabilities, confidence: familyResult.confidence ?? null,
|
|
1263
|
+
latencyMs: familyResult.latencyMs, criteria: familyStage.criteria,
|
|
1264
|
+
top: familyResult.top ?? null, second: familyResult.second ?? null, gap: familyResult.gap ?? null,
|
|
1265
|
+
},
|
|
1266
|
+
sizing: sizingResult ? {
|
|
1267
|
+
choice: sizingResult.choice, probabilities: isJev ? sizingResult.probabilities ?? null : null,
|
|
1268
|
+
confidence: sizingResult.confidence ?? null, latencyMs: sizingResult.latencyMs,
|
|
1269
|
+
criteria: sizeStage.criteria, amount: chosenSize?.amount ?? null,
|
|
1270
|
+
top: sizingResult.top ?? null, second: sizingResult.second ?? null,
|
|
1271
|
+
} : null,
|
|
1272
|
+
primaryDecisionLatencyMs,
|
|
1273
|
+
publicReason: '',
|
|
1274
|
+
model: familyResult.model || agent.model,
|
|
1275
|
+
usage: usage.total_tokens ? usage : (familyResult.usage ?? null),
|
|
1276
|
+
meta: {
|
|
1277
|
+
decisionArchitecture: DECISION_ARCHITECTURE_VERSION,
|
|
1278
|
+
method: isJev ? (protocol === 'jev_native' ? 'jev-native-hierarchical' : 'openrouter-decisions-hierarchical') : `${familyResult.protocol ?? protocol}-hierarchical`,
|
|
1279
|
+
requestedMethod: protocol, endpoint: connection.name,
|
|
1280
|
+
protocolFallback: familyResult.protocolFallback ?? null,
|
|
1281
|
+
protocolFallbackTriggered: Boolean(familyResult.protocolFallback),
|
|
1282
|
+
retryCount: Number(familyResult.retryCount || 0), incidents: familyResult.incidents ?? [],
|
|
1283
|
+
family: { choice: familyResult.choice },
|
|
1284
|
+
sizing: sizingResult ? { choice: sizingResult.choice } : null,
|
|
1285
|
+
},
|
|
1286
|
+
};
|
|
1287
|
+
} finally { cancel(); }
|
|
1288
|
+
}
|