@apa-network/agent-sdk 0.2.0-beta.3 → 0.2.0-beta.4
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/README.md +9 -18
- package/dist/bot/createBot.d.ts +14 -0
- package/dist/bot/createBot.js +108 -0
- package/dist/cli.js +35 -40
- package/dist/http/client.d.ts +1 -1
- package/dist/http/client.js +3 -4
- package/dist/types/bot.d.ts +43 -0
- package/dist/types/bot.js +1 -0
- package/dist/types/messages.d.ts +85 -0
- package/dist/types/messages.js +1 -0
- package/dist/utils/action.d.ts +3 -0
- package/dist/utils/action.js +10 -0
- package/dist/utils/action.test.d.ts +1 -0
- package/dist/utils/action.test.js +10 -0
- package/dist/utils/backoff.d.ts +2 -0
- package/dist/utils/backoff.js +11 -0
- package/dist/utils/backoff.test.d.ts +1 -0
- package/dist/utils/backoff.test.js +8 -0
- package/dist/ws/client.d.ts +38 -0
- package/dist/ws/client.js +274 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -25,14 +25,17 @@ CLI args override env vars.
|
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
apa-bot register --name BotA --description "test"
|
|
28
|
-
apa-bot
|
|
29
|
-
apa-bot me
|
|
30
|
-
apa-bot bind-key --
|
|
31
|
-
apa-bot loop --join random
|
|
28
|
+
apa-bot claim --claim-url http://localhost:8080/claim/apa_claim_xxx
|
|
29
|
+
apa-bot me
|
|
30
|
+
apa-bot bind-key --provider openai --vendor-key sk-... --budget-usd 10
|
|
31
|
+
apa-bot loop --join random
|
|
32
32
|
apa-bot doctor
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`
|
|
35
|
+
`claim` accepts `--claim-url` or `--claim-code` from the register response.
|
|
36
|
+
`me` uses `GET /api/agents/me` and always reads the API key from the cached credential.
|
|
37
|
+
|
|
38
|
+
`loop` command runs the lifecycle (register → match → play) and emits JSON lines:
|
|
36
39
|
- `ready`, `server_event`, `decision_request`, `action_result`, `decision_timeout`
|
|
37
40
|
|
|
38
41
|
Example (no local repository required, callback-based decisions):
|
|
@@ -41,8 +44,6 @@ Example (no local repository required, callback-based decisions):
|
|
|
41
44
|
npx @apa-network/agent-sdk loop \
|
|
42
45
|
--api-base http://localhost:8080 \
|
|
43
46
|
--join random \
|
|
44
|
-
--provider openai \
|
|
45
|
-
--vendor-key sk-... \
|
|
46
47
|
--callback-addr 127.0.0.1:8787
|
|
47
48
|
```
|
|
48
49
|
|
|
@@ -58,17 +59,7 @@ npx @apa-network/agent-sdk loop \
|
|
|
58
59
|
Only one credential is stored locally at a time; new registrations overwrite the previous one.
|
|
59
60
|
Loop reads credentials from the cache and does not accept identity args.
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
export OPENAI_API_KEY=sk-...
|
|
65
|
-
npx @apa-network/agent-sdk loop \
|
|
66
|
-
--api-base http://localhost:8080 \
|
|
67
|
-
--join random \
|
|
68
|
-
--provider openai \
|
|
69
|
-
--vendor-key-env OPENAI_API_KEY \
|
|
70
|
-
--callback-addr 127.0.0.1:8787
|
|
71
|
-
```
|
|
62
|
+
Funding is handled separately via `bind-key` (not inside `loop`).
|
|
72
63
|
|
|
73
64
|
When a `decision_request` appears, POST to the callback URL:
|
|
74
65
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CreateBotOptions, StrategyFn } from "../types/bot.js";
|
|
2
|
+
import type { EventLogEvent, HandEndEvent, JoinResultEvent } from "../types/messages.js";
|
|
3
|
+
type BotEvents = {
|
|
4
|
+
join: JoinResultEvent;
|
|
5
|
+
handEnd: HandEndEvent;
|
|
6
|
+
error: unknown;
|
|
7
|
+
eventLog: EventLogEvent;
|
|
8
|
+
};
|
|
9
|
+
export declare function createBot(opts: CreateBotOptions): {
|
|
10
|
+
play: (strategy: StrategyFn) => Promise<void>;
|
|
11
|
+
stop: () => Promise<void>;
|
|
12
|
+
on: <K extends keyof BotEvents>(event: K, cb: (payload: BotEvents[K]) => void) => void;
|
|
13
|
+
};
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { APAWsClient } from "../ws/client.js";
|
|
3
|
+
import { nextRequestId, validateBotAction } from "../utils/action.js";
|
|
4
|
+
const DEFAULT_GUARD_MS = 300;
|
|
5
|
+
function withTimeout(promise, timeoutMs) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const timer = setTimeout(() => reject(new Error("strategy_timeout")), timeoutMs);
|
|
8
|
+
promise
|
|
9
|
+
.then((value) => {
|
|
10
|
+
clearTimeout(timer);
|
|
11
|
+
resolve(value);
|
|
12
|
+
})
|
|
13
|
+
.catch((err) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
reject(err);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function buildTurnKey(s) {
|
|
20
|
+
return `${s.hand_id}:${s.street}:${s.current_actor_seat}:${s.current_bet}:${s.call_amount}:${s.pot}`;
|
|
21
|
+
}
|
|
22
|
+
export function createBot(opts) {
|
|
23
|
+
const emitter = new EventEmitter();
|
|
24
|
+
const ws = new APAWsClient({
|
|
25
|
+
apiBase: opts.apiBase,
|
|
26
|
+
wsUrl: opts.wsUrl,
|
|
27
|
+
agentId: opts.agentId,
|
|
28
|
+
apiKey: opts.apiKey,
|
|
29
|
+
join: opts.join,
|
|
30
|
+
reconnect: opts.reconnect
|
|
31
|
+
});
|
|
32
|
+
let running = false;
|
|
33
|
+
let lastTurnKey = "";
|
|
34
|
+
ws.on("join_result", (evt) => {
|
|
35
|
+
if (!evt.ok) {
|
|
36
|
+
emitter.emit("error", new Error(`join failed: ${evt.error || "unknown"}`));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
emitter.emit("join", evt);
|
|
40
|
+
});
|
|
41
|
+
ws.on("event_log", (evt) => emitter.emit("eventLog", evt));
|
|
42
|
+
ws.on("hand_end", (evt) => {
|
|
43
|
+
lastTurnKey = "";
|
|
44
|
+
emitter.emit("handEnd", evt);
|
|
45
|
+
});
|
|
46
|
+
ws.on("error", (err) => emitter.emit("error", err));
|
|
47
|
+
async function play(strategy) {
|
|
48
|
+
running = true;
|
|
49
|
+
await ws.connect();
|
|
50
|
+
ws.on("state_update", async (state) => {
|
|
51
|
+
if (!running) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (state.current_actor_seat !== state.my_seat) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const turnKey = buildTurnKey(state);
|
|
58
|
+
if (turnKey === lastTurnKey) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
lastTurnKey = turnKey;
|
|
62
|
+
const ctx = {
|
|
63
|
+
gameId: state.game_id,
|
|
64
|
+
handId: state.hand_id,
|
|
65
|
+
mySeat: state.my_seat,
|
|
66
|
+
currentActorSeat: state.current_actor_seat,
|
|
67
|
+
minRaise: state.min_raise,
|
|
68
|
+
currentBet: state.current_bet,
|
|
69
|
+
callAmount: state.call_amount,
|
|
70
|
+
myBalance: state.my_balance,
|
|
71
|
+
communityCards: state.community_cards,
|
|
72
|
+
holeCards: state.hole_cards || [],
|
|
73
|
+
raw: state
|
|
74
|
+
};
|
|
75
|
+
const safetyMs = Math.max(100, state.action_timeout_ms - (opts.actionTimeoutGuardMs ?? DEFAULT_GUARD_MS));
|
|
76
|
+
try {
|
|
77
|
+
const action = await withTimeout(Promise.resolve(strategy(ctx)), safetyMs);
|
|
78
|
+
validateBotAction(action);
|
|
79
|
+
await ws.sendAction({
|
|
80
|
+
type: "action",
|
|
81
|
+
request_id: nextRequestId(),
|
|
82
|
+
action: action.action,
|
|
83
|
+
amount: "amount" in action ? action.amount : undefined
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
await ws.sendAction({
|
|
88
|
+
type: "action",
|
|
89
|
+
request_id: nextRequestId(),
|
|
90
|
+
action: "fold"
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
ws.on("action_result", (res) => {
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
emitter.emit("error", new Error(`action failed: ${res.error || "unknown"} (${res.request_id})`));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async function stop() {
|
|
101
|
+
running = false;
|
|
102
|
+
await ws.stop();
|
|
103
|
+
}
|
|
104
|
+
function on(event, cb) {
|
|
105
|
+
emitter.on(event, cb);
|
|
106
|
+
}
|
|
107
|
+
return { play, stop, on };
|
|
108
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -49,16 +49,32 @@ function readNumber(args, key, fallback) {
|
|
|
49
49
|
function printHelp() {
|
|
50
50
|
console.log(`apa-bot commands:
|
|
51
51
|
apa-bot register --name <name> --description <desc> [--api-base <url>]
|
|
52
|
-
apa-bot
|
|
53
|
-
apa-bot me
|
|
54
|
-
apa-bot bind-key --
|
|
52
|
+
apa-bot claim (--claim-code <code> | --claim-url <url>) [--api-base <url>]
|
|
53
|
+
apa-bot me [--api-base <url>]
|
|
54
|
+
apa-bot bind-key --provider <openai|kimi> --vendor-key <key> --budget-usd <num> [--api-base <url>]
|
|
55
55
|
apa-bot loop --join <random|select> [--room-id <id>]
|
|
56
|
-
[--
|
|
57
|
-
[--budget-usd <num>] [--callback-addr <host:port>] [--decision-timeout-ms <ms>] [--api-base <url>]
|
|
56
|
+
[--callback-addr <host:port>] [--decision-timeout-ms <ms>] [--api-base <url>]
|
|
58
57
|
apa-bot doctor [--api-base <url>]
|
|
59
58
|
|
|
60
59
|
Config priority: CLI args > env (API_BASE) > defaults.`);
|
|
61
60
|
}
|
|
61
|
+
async function requireApiKey(apiBase) {
|
|
62
|
+
const cached = await loadCredential(apiBase, undefined);
|
|
63
|
+
if (!cached?.api_key) {
|
|
64
|
+
throw new Error("api_key_not_found (run apa-bot register)");
|
|
65
|
+
}
|
|
66
|
+
return cached.api_key;
|
|
67
|
+
}
|
|
68
|
+
function claimCodeFromUrl(raw) {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(raw);
|
|
71
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
72
|
+
return parts[parts.length - 1] || "";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
62
78
|
function emit(message) {
|
|
63
79
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
64
80
|
}
|
|
@@ -137,24 +153,11 @@ function pickRoom(rooms, joinMode, roomId) {
|
|
|
137
153
|
const sorted = [...rooms].sort((a, b) => a.min_buyin_cc - b.min_buyin_cc);
|
|
138
154
|
return { id: sorted[0].id, min_buyin_cc: sorted[0].min_buyin_cc };
|
|
139
155
|
}
|
|
140
|
-
function getVendorKey(args) {
|
|
141
|
-
const direct = readString(args, "vendor-key");
|
|
142
|
-
if (direct) {
|
|
143
|
-
return direct;
|
|
144
|
-
}
|
|
145
|
-
const envName = readString(args, "vendor-key-env");
|
|
146
|
-
if (envName && process.env[envName]) {
|
|
147
|
-
return process.env[envName] || "";
|
|
148
|
-
}
|
|
149
|
-
return "";
|
|
150
|
-
}
|
|
151
156
|
async function runLoop(args) {
|
|
152
157
|
const apiBase = resolveApiBase(readString(args, "api-base", "API_BASE"));
|
|
153
158
|
const joinRaw = requireArg("--join", readString(args, "join"));
|
|
154
159
|
const joinMode = joinRaw === "select" ? "select" : "random";
|
|
155
160
|
const roomId = joinMode === "select" ? requireArg("--room-id", readString(args, "room-id")) : undefined;
|
|
156
|
-
const provider = readString(args, "provider") || "";
|
|
157
|
-
const budgetUsd = readNumber(args, "budget-usd", 10);
|
|
158
161
|
const callbackAddr = readString(args, "callback-addr") || "127.0.0.1:8787";
|
|
159
162
|
const decisionTimeoutMs = readNumber(args, "decision-timeout-ms", 5000);
|
|
160
163
|
const client = new APAHttpClient({ apiBase });
|
|
@@ -164,9 +167,9 @@ async function runLoop(args) {
|
|
|
164
167
|
}
|
|
165
168
|
const agentId = cached.agent_id;
|
|
166
169
|
const apiKey = cached.api_key;
|
|
167
|
-
const
|
|
168
|
-
emit({ type: "agent_status", status });
|
|
169
|
-
if (
|
|
170
|
+
const meStatus = await client.getAgentMe(apiKey);
|
|
171
|
+
emit({ type: "agent_status", status: meStatus });
|
|
172
|
+
if (meStatus?.status === "pending") {
|
|
170
173
|
emit({ type: "claim_required", message: "agent is pending; complete claim before starting loop" });
|
|
171
174
|
return;
|
|
172
175
|
}
|
|
@@ -175,20 +178,7 @@ async function runLoop(args) {
|
|
|
175
178
|
const rooms = await client.listPublicRooms();
|
|
176
179
|
const pickedRoom = pickRoom(rooms.items || [], joinMode, roomId);
|
|
177
180
|
if (balance < pickedRoom.min_buyin_cc) {
|
|
178
|
-
|
|
179
|
-
if (!provider) {
|
|
180
|
-
throw new Error("provider_required_for_topup");
|
|
181
|
-
}
|
|
182
|
-
if (!vendorKey) {
|
|
183
|
-
throw new Error("vendor_key_required_for_topup");
|
|
184
|
-
}
|
|
185
|
-
emit({ type: "topup_start", provider, budget_usd: budgetUsd });
|
|
186
|
-
await client.bindKey({ apiKey, provider, vendorKey, budgetUsd });
|
|
187
|
-
me = await client.getAgentMe(apiKey);
|
|
188
|
-
balance = Number(me?.balance_cc ?? 0);
|
|
189
|
-
}
|
|
190
|
-
if (balance < pickedRoom.min_buyin_cc) {
|
|
191
|
-
throw new Error(`insufficient_balance_after_topup (balance=${balance}, min=${pickedRoom.min_buyin_cc})`);
|
|
181
|
+
throw new Error(`insufficient_balance (balance=${balance}, min=${pickedRoom.min_buyin_cc})`);
|
|
192
182
|
}
|
|
193
183
|
const callbackServer = new DecisionCallbackServer(callbackAddr);
|
|
194
184
|
const callbackURL = await callbackServer.start();
|
|
@@ -303,23 +293,28 @@ async function run() {
|
|
|
303
293
|
console.log(JSON.stringify(result, null, 2));
|
|
304
294
|
return;
|
|
305
295
|
}
|
|
306
|
-
case "
|
|
296
|
+
case "claim": {
|
|
307
297
|
const client = new APAHttpClient({ apiBase });
|
|
308
|
-
const
|
|
309
|
-
const
|
|
298
|
+
const claimCode = readString(args, "claim-code");
|
|
299
|
+
const claimURL = readString(args, "claim-url");
|
|
300
|
+
const code = claimCode || (claimURL ? claimCodeFromUrl(claimURL) : "");
|
|
301
|
+
if (!code) {
|
|
302
|
+
throw new Error("claim_code_required (--claim-code or --claim-url)");
|
|
303
|
+
}
|
|
304
|
+
const result = await client.claimByCode(code);
|
|
310
305
|
console.log(JSON.stringify(result, null, 2));
|
|
311
306
|
return;
|
|
312
307
|
}
|
|
313
308
|
case "me": {
|
|
314
309
|
const client = new APAHttpClient({ apiBase });
|
|
315
|
-
const apiKey =
|
|
310
|
+
const apiKey = await requireApiKey(apiBase);
|
|
316
311
|
const result = await client.getAgentMe(apiKey);
|
|
317
312
|
console.log(JSON.stringify(result, null, 2));
|
|
318
313
|
return;
|
|
319
314
|
}
|
|
320
315
|
case "bind-key": {
|
|
321
316
|
const client = new APAHttpClient({ apiBase });
|
|
322
|
-
const apiKey =
|
|
317
|
+
const apiKey = await requireApiKey(apiBase);
|
|
323
318
|
const provider = requireArg("--provider", readString(args, "provider"));
|
|
324
319
|
const vendorKey = requireArg("--vendor-key", readString(args, "vendor-key"));
|
|
325
320
|
const budgetUsd = readNumber(args, "budget-usd");
|
package/dist/http/client.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export declare class APAHttpClient {
|
|
|
27
27
|
name: string;
|
|
28
28
|
description: string;
|
|
29
29
|
}): Promise<any>;
|
|
30
|
-
|
|
30
|
+
claimByCode(claimCode: string): Promise<any>;
|
|
31
31
|
getAgentMe(apiKey: string): Promise<any>;
|
|
32
32
|
bindKey(input: {
|
|
33
33
|
apiKey: string;
|
package/dist/http/client.js
CHANGED
|
@@ -39,10 +39,9 @@ export class APAHttpClient {
|
|
|
39
39
|
});
|
|
40
40
|
return parseJson(res);
|
|
41
41
|
}
|
|
42
|
-
async
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
});
|
|
42
|
+
async claimByCode(claimCode) {
|
|
43
|
+
const base = this.apiBase.replace(/\/api\/?$/, "");
|
|
44
|
+
const res = await fetch(`${base}/claim/${encodeURIComponent(claimCode)}`);
|
|
46
45
|
return parseJson(res);
|
|
47
46
|
}
|
|
48
47
|
async getAgentMe(apiKey) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { JoinMode, StateUpdateEvent } from "./messages.js";
|
|
2
|
+
export type BotAction = {
|
|
3
|
+
action: "fold";
|
|
4
|
+
} | {
|
|
5
|
+
action: "check";
|
|
6
|
+
} | {
|
|
7
|
+
action: "call";
|
|
8
|
+
} | {
|
|
9
|
+
action: "raise";
|
|
10
|
+
amount: number;
|
|
11
|
+
} | {
|
|
12
|
+
action: "bet";
|
|
13
|
+
amount: number;
|
|
14
|
+
};
|
|
15
|
+
export type PlayContext = {
|
|
16
|
+
gameId: string;
|
|
17
|
+
handId: string;
|
|
18
|
+
mySeat: number;
|
|
19
|
+
currentActorSeat: number;
|
|
20
|
+
minRaise: number;
|
|
21
|
+
currentBet: number;
|
|
22
|
+
callAmount: number;
|
|
23
|
+
myBalance: number;
|
|
24
|
+
communityCards: string[];
|
|
25
|
+
holeCards: string[];
|
|
26
|
+
raw: StateUpdateEvent;
|
|
27
|
+
};
|
|
28
|
+
export type StrategyFn = (ctx: PlayContext) => BotAction | Promise<BotAction>;
|
|
29
|
+
export type ReconnectOptions = {
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
baseMs?: number;
|
|
32
|
+
maxMs?: number;
|
|
33
|
+
jitter?: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type CreateBotOptions = {
|
|
36
|
+
apiBase?: string;
|
|
37
|
+
wsUrl?: string;
|
|
38
|
+
agentId: string;
|
|
39
|
+
apiKey: string;
|
|
40
|
+
join: JoinMode;
|
|
41
|
+
reconnect?: ReconnectOptions;
|
|
42
|
+
actionTimeoutGuardMs?: number;
|
|
43
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type JoinMode = {
|
|
2
|
+
mode: "random";
|
|
3
|
+
} | {
|
|
4
|
+
mode: "select";
|
|
5
|
+
roomId: string;
|
|
6
|
+
};
|
|
7
|
+
export type JoinMessage = {
|
|
8
|
+
type: "join";
|
|
9
|
+
agent_id: string;
|
|
10
|
+
api_key: string;
|
|
11
|
+
join_mode: "random" | "select";
|
|
12
|
+
room_id?: string;
|
|
13
|
+
};
|
|
14
|
+
export type ActionMessage = {
|
|
15
|
+
type: "action";
|
|
16
|
+
request_id: string;
|
|
17
|
+
action: "fold" | "check" | "call" | "raise" | "bet";
|
|
18
|
+
amount?: number;
|
|
19
|
+
thought_log?: string;
|
|
20
|
+
};
|
|
21
|
+
export type JoinResultEvent = {
|
|
22
|
+
type: "join_result";
|
|
23
|
+
protocol_version: string;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
room_id?: string;
|
|
27
|
+
};
|
|
28
|
+
export type StateUpdateEvent = {
|
|
29
|
+
type: "state_update";
|
|
30
|
+
protocol_version: string;
|
|
31
|
+
game_id: string;
|
|
32
|
+
hand_id: string;
|
|
33
|
+
my_seat: number;
|
|
34
|
+
current_actor_seat: number;
|
|
35
|
+
min_raise: number;
|
|
36
|
+
current_bet: number;
|
|
37
|
+
call_amount: number;
|
|
38
|
+
my_balance: number;
|
|
39
|
+
action_timeout_ms: number;
|
|
40
|
+
street: string;
|
|
41
|
+
hole_cards?: string[];
|
|
42
|
+
community_cards: string[];
|
|
43
|
+
pot: number;
|
|
44
|
+
opponents: Array<{
|
|
45
|
+
seat: number;
|
|
46
|
+
name: string;
|
|
47
|
+
stack: number;
|
|
48
|
+
action: string;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
export type ActionResultEvent = {
|
|
52
|
+
type: "action_result";
|
|
53
|
+
protocol_version: string;
|
|
54
|
+
request_id: string;
|
|
55
|
+
ok: boolean;
|
|
56
|
+
error?: string;
|
|
57
|
+
};
|
|
58
|
+
export type EventLogEvent = {
|
|
59
|
+
type: "event_log";
|
|
60
|
+
protocol_version: string;
|
|
61
|
+
timestamp_ms: number;
|
|
62
|
+
player_seat: number;
|
|
63
|
+
action: string;
|
|
64
|
+
amount?: number;
|
|
65
|
+
thought_log?: string;
|
|
66
|
+
event: string;
|
|
67
|
+
};
|
|
68
|
+
export type HandEndEvent = {
|
|
69
|
+
type: "hand_end";
|
|
70
|
+
protocol_version: string;
|
|
71
|
+
winner: string;
|
|
72
|
+
pot: number;
|
|
73
|
+
balances: Array<{
|
|
74
|
+
agent_id: string;
|
|
75
|
+
balance: number;
|
|
76
|
+
}>;
|
|
77
|
+
showdown?: Array<{
|
|
78
|
+
agent_id: string;
|
|
79
|
+
hole_cards: string[];
|
|
80
|
+
}>;
|
|
81
|
+
};
|
|
82
|
+
export type ServerEvent = JoinResultEvent | StateUpdateEvent | ActionResultEvent | EventLogEvent | HandEndEvent | {
|
|
83
|
+
type: string;
|
|
84
|
+
[k: string]: unknown;
|
|
85
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function validateBotAction(action) {
|
|
2
|
+
if (action.action === "raise" || action.action === "bet") {
|
|
3
|
+
if (!Number.isFinite(action.amount) || action.amount <= 0) {
|
|
4
|
+
throw new Error(`${action.action} requires positive amount`);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function nextRequestId() {
|
|
9
|
+
return `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000_000)}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { validateBotAction } from "./action.js";
|
|
4
|
+
test("validateBotAction accepts non-amount actions", () => {
|
|
5
|
+
validateBotAction({ action: "check" });
|
|
6
|
+
validateBotAction({ action: "fold" });
|
|
7
|
+
});
|
|
8
|
+
test("validateBotAction rejects invalid raise amount", () => {
|
|
9
|
+
assert.throws(() => validateBotAction({ action: "raise", amount: 0 }), /positive amount/);
|
|
10
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function computeBackoffMs(attempt, baseMs, maxMs, jitter) {
|
|
2
|
+
const exponential = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
|
|
3
|
+
if (!jitter) {
|
|
4
|
+
return exponential;
|
|
5
|
+
}
|
|
6
|
+
const spread = Math.max(1, Math.floor(exponential * 0.2));
|
|
7
|
+
return exponential - spread + Math.floor(Math.random() * spread * 2);
|
|
8
|
+
}
|
|
9
|
+
export function sleep(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { computeBackoffMs } from "./backoff.js";
|
|
4
|
+
test("computeBackoffMs grows exponentially without jitter", () => {
|
|
5
|
+
assert.equal(computeBackoffMs(1, 500, 8000, false), 500);
|
|
6
|
+
assert.equal(computeBackoffMs(2, 500, 8000, false), 1000);
|
|
7
|
+
assert.equal(computeBackoffMs(5, 500, 8000, false), 8000);
|
|
8
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { ActionMessage, JoinMode } from "../types/messages.js";
|
|
3
|
+
type WsOptions = {
|
|
4
|
+
apiBase?: string;
|
|
5
|
+
wsUrl?: string;
|
|
6
|
+
agentId: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
join: JoinMode;
|
|
9
|
+
reconnect?: {
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
baseMs?: number;
|
|
12
|
+
maxMs?: number;
|
|
13
|
+
jitter?: boolean;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export declare class APAWsClient extends EventEmitter {
|
|
17
|
+
private readonly apiBase;
|
|
18
|
+
private readonly agentId;
|
|
19
|
+
private readonly apiKey;
|
|
20
|
+
private readonly join;
|
|
21
|
+
private readonly reconnect;
|
|
22
|
+
private shouldRun;
|
|
23
|
+
private connectAttempt;
|
|
24
|
+
private sessionId;
|
|
25
|
+
private tableId;
|
|
26
|
+
private roomId;
|
|
27
|
+
private turnId;
|
|
28
|
+
private streamAbort;
|
|
29
|
+
constructor(opts: WsOptions);
|
|
30
|
+
connect(): Promise<void>;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
sendAction(action: ActionMessage): Promise<void>;
|
|
33
|
+
private openSessionAndStream;
|
|
34
|
+
private readEventStream;
|
|
35
|
+
private handleEnvelope;
|
|
36
|
+
private reconnectLoop;
|
|
37
|
+
}
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { computeBackoffMs, sleep } from "../utils/backoff.js";
|
|
3
|
+
import { resolveApiBase } from "../utils/config.js";
|
|
4
|
+
const DEFAULT_RECONNECT = {
|
|
5
|
+
enabled: true,
|
|
6
|
+
baseMs: 500,
|
|
7
|
+
maxMs: 8000,
|
|
8
|
+
jitter: true
|
|
9
|
+
};
|
|
10
|
+
function apiOrigin(apiBase) {
|
|
11
|
+
return apiBase.replace(/\/api\/?$/, "");
|
|
12
|
+
}
|
|
13
|
+
export class APAWsClient extends EventEmitter {
|
|
14
|
+
apiBase;
|
|
15
|
+
agentId;
|
|
16
|
+
apiKey;
|
|
17
|
+
join;
|
|
18
|
+
reconnect;
|
|
19
|
+
shouldRun = true;
|
|
20
|
+
connectAttempt = 0;
|
|
21
|
+
sessionId = "";
|
|
22
|
+
tableId = "";
|
|
23
|
+
roomId = "";
|
|
24
|
+
turnId = "";
|
|
25
|
+
streamAbort = null;
|
|
26
|
+
constructor(opts) {
|
|
27
|
+
super();
|
|
28
|
+
this.apiBase = resolveApiBase(opts.apiBase);
|
|
29
|
+
this.agentId = opts.agentId;
|
|
30
|
+
this.apiKey = opts.apiKey;
|
|
31
|
+
this.join = opts.join;
|
|
32
|
+
this.reconnect = {
|
|
33
|
+
enabled: opts.reconnect?.enabled ?? DEFAULT_RECONNECT.enabled,
|
|
34
|
+
baseMs: opts.reconnect?.baseMs ?? DEFAULT_RECONNECT.baseMs,
|
|
35
|
+
maxMs: opts.reconnect?.maxMs ?? DEFAULT_RECONNECT.maxMs,
|
|
36
|
+
jitter: opts.reconnect?.jitter ?? DEFAULT_RECONNECT.jitter
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async connect() {
|
|
40
|
+
this.shouldRun = true;
|
|
41
|
+
await this.openSessionAndStream();
|
|
42
|
+
}
|
|
43
|
+
async stop() {
|
|
44
|
+
this.shouldRun = false;
|
|
45
|
+
if (this.streamAbort) {
|
|
46
|
+
this.streamAbort.abort();
|
|
47
|
+
this.streamAbort = null;
|
|
48
|
+
}
|
|
49
|
+
if (this.sessionId) {
|
|
50
|
+
try {
|
|
51
|
+
await fetch(`${this.apiBase}/agent/sessions/${this.sessionId}`, { method: "DELETE" });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// ignore best-effort close
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async sendAction(action) {
|
|
59
|
+
if (!this.sessionId) {
|
|
60
|
+
throw new Error("session not created");
|
|
61
|
+
}
|
|
62
|
+
if (!this.turnId) {
|
|
63
|
+
throw new Error("turn_id unavailable");
|
|
64
|
+
}
|
|
65
|
+
const res = await fetch(`${this.apiBase}/agent/sessions/${this.sessionId}/actions`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "content-type": "application/json" },
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
request_id: action.request_id,
|
|
70
|
+
turn_id: this.turnId,
|
|
71
|
+
action: action.action,
|
|
72
|
+
amount: action.amount,
|
|
73
|
+
thought_log: action.thought_log
|
|
74
|
+
})
|
|
75
|
+
});
|
|
76
|
+
const payload = await res.json().catch(() => ({}));
|
|
77
|
+
const evt = {
|
|
78
|
+
type: "action_result",
|
|
79
|
+
protocol_version: "",
|
|
80
|
+
request_id: action.request_id,
|
|
81
|
+
ok: res.ok && payload.accepted !== false,
|
|
82
|
+
error: payload.reason
|
|
83
|
+
};
|
|
84
|
+
this.emit("action_result", evt);
|
|
85
|
+
}
|
|
86
|
+
async openSessionAndStream() {
|
|
87
|
+
const createRes = await fetch(`${this.apiBase}/agent/sessions`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json" },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
agent_id: this.agentId,
|
|
92
|
+
api_key: this.apiKey,
|
|
93
|
+
join_mode: this.join.mode,
|
|
94
|
+
room_id: this.join.mode === "select" ? this.join.roomId : undefined
|
|
95
|
+
})
|
|
96
|
+
});
|
|
97
|
+
if (!createRes.ok) {
|
|
98
|
+
throw new Error(`create session failed (${createRes.status})`);
|
|
99
|
+
}
|
|
100
|
+
const created = await createRes.json();
|
|
101
|
+
this.sessionId = created.session_id;
|
|
102
|
+
this.tableId = created.table_id || "";
|
|
103
|
+
this.roomId = created.room_id || "";
|
|
104
|
+
this.connectAttempt = 0;
|
|
105
|
+
this.emit("connected");
|
|
106
|
+
this.emit("join_result", {
|
|
107
|
+
type: "join_result",
|
|
108
|
+
protocol_version: "",
|
|
109
|
+
ok: true,
|
|
110
|
+
room_id: this.roomId
|
|
111
|
+
});
|
|
112
|
+
await this.readEventStream(created.stream_url);
|
|
113
|
+
}
|
|
114
|
+
async readEventStream(streamPath) {
|
|
115
|
+
const origin = apiOrigin(this.apiBase);
|
|
116
|
+
const url = streamPath.startsWith("http") ? streamPath : `${origin}${streamPath}`;
|
|
117
|
+
this.streamAbort = new AbortController();
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch(url, {
|
|
120
|
+
method: "GET",
|
|
121
|
+
headers: { accept: "text/event-stream" },
|
|
122
|
+
signal: this.streamAbort.signal
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok || !res.body) {
|
|
125
|
+
throw new Error(`stream open failed (${res.status})`);
|
|
126
|
+
}
|
|
127
|
+
const reader = res.body.getReader();
|
|
128
|
+
const decoder = new TextDecoder();
|
|
129
|
+
let buffer = "";
|
|
130
|
+
let currentData = "";
|
|
131
|
+
while (this.shouldRun) {
|
|
132
|
+
const { done, value } = await reader.read();
|
|
133
|
+
if (done) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
buffer += decoder.decode(value, { stream: true });
|
|
137
|
+
const lines = buffer.split("\n");
|
|
138
|
+
buffer = lines.pop() || "";
|
|
139
|
+
for (const rawLine of lines) {
|
|
140
|
+
const line = rawLine.trimEnd();
|
|
141
|
+
if (line.startsWith("data: ")) {
|
|
142
|
+
currentData = line.slice(6);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (line === "") {
|
|
146
|
+
if (!currentData) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
this.handleEnvelope(currentData);
|
|
150
|
+
currentData = "";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this.emit("disconnected");
|
|
155
|
+
if (this.shouldRun && this.reconnect.enabled) {
|
|
156
|
+
await this.reconnectLoop();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (!this.shouldRun) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.emit("error", err);
|
|
164
|
+
if (this.reconnect.enabled) {
|
|
165
|
+
await this.reconnectLoop();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
handleEnvelope(payloadText) {
|
|
170
|
+
let envelope;
|
|
171
|
+
try {
|
|
172
|
+
envelope = JSON.parse(payloadText);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
this.emit("error", err);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const evt = envelope?.event;
|
|
179
|
+
const data = envelope?.data || {};
|
|
180
|
+
if (evt === "turn_started") {
|
|
181
|
+
this.turnId = data.turn_id || "";
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (evt === "state_snapshot") {
|
|
185
|
+
this.turnId = data.turn_id || this.turnId;
|
|
186
|
+
const seats = Array.isArray(data.seats) ? data.seats : [];
|
|
187
|
+
const me = seats.find((s) => s.seat_id === data.my_seat) || {};
|
|
188
|
+
const currentBet = seats.reduce((m, s) => Math.max(m, Number(s.street_contribution || 0)), 0);
|
|
189
|
+
const serverEvt = {
|
|
190
|
+
type: "state_update",
|
|
191
|
+
protocol_version: "",
|
|
192
|
+
game_id: this.tableId,
|
|
193
|
+
hand_id: data.hand_id || "",
|
|
194
|
+
my_seat: data.my_seat ?? 0,
|
|
195
|
+
current_actor_seat: data.current_actor_seat ?? 0,
|
|
196
|
+
min_raise: 0,
|
|
197
|
+
current_bet: currentBet,
|
|
198
|
+
call_amount: Number(me.to_call || 0),
|
|
199
|
+
my_balance: Number(data.my_balance || 0),
|
|
200
|
+
action_timeout_ms: Number(data.action_timeout_ms || 5000),
|
|
201
|
+
street: data.street || "preflop",
|
|
202
|
+
hole_cards: data.my_hole_cards || [],
|
|
203
|
+
community_cards: data.community_cards || [],
|
|
204
|
+
pot: Number(data.pot || 0),
|
|
205
|
+
opponents: seats
|
|
206
|
+
.filter((s) => s.seat_id !== data.my_seat)
|
|
207
|
+
.map((s) => ({
|
|
208
|
+
seat: Number(s.seat_id),
|
|
209
|
+
name: String(s.agent_id || ""),
|
|
210
|
+
stack: Number(s.stack || 0),
|
|
211
|
+
action: String(s.last_action || "")
|
|
212
|
+
}))
|
|
213
|
+
};
|
|
214
|
+
this.emit("event", serverEvt);
|
|
215
|
+
this.emit("state_update", serverEvt);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (evt === "action_accepted") {
|
|
219
|
+
const serverEvt = {
|
|
220
|
+
type: "action_result",
|
|
221
|
+
protocol_version: "",
|
|
222
|
+
request_id: data.request_id || "",
|
|
223
|
+
ok: true
|
|
224
|
+
};
|
|
225
|
+
this.emit("event", serverEvt);
|
|
226
|
+
this.emit("action_result", serverEvt);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (evt === "action_rejected") {
|
|
230
|
+
const serverEvt = {
|
|
231
|
+
type: "action_result",
|
|
232
|
+
protocol_version: "",
|
|
233
|
+
request_id: data.request_id || "",
|
|
234
|
+
ok: false,
|
|
235
|
+
error: data.reason || "invalid_action"
|
|
236
|
+
};
|
|
237
|
+
this.emit("event", serverEvt);
|
|
238
|
+
this.emit("action_result", serverEvt);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (evt === "hand_end") {
|
|
242
|
+
const serverEvt = {
|
|
243
|
+
type: "hand_end",
|
|
244
|
+
protocol_version: "",
|
|
245
|
+
winner: data.winner || "",
|
|
246
|
+
pot: Number(data.pot || 0),
|
|
247
|
+
balances: data.balances || [],
|
|
248
|
+
showdown: data.showdown || []
|
|
249
|
+
};
|
|
250
|
+
this.emit("event", serverEvt);
|
|
251
|
+
this.emit("hand_end", serverEvt);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (evt === "session_closed") {
|
|
255
|
+
this.emit("disconnected");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async reconnectLoop() {
|
|
260
|
+
while (this.shouldRun) {
|
|
261
|
+
this.connectAttempt += 1;
|
|
262
|
+
const waitMs = computeBackoffMs(this.connectAttempt, this.reconnect.baseMs, this.reconnect.maxMs, this.reconnect.jitter);
|
|
263
|
+
this.emit("reconnect", { attempt: this.connectAttempt, waitMs });
|
|
264
|
+
await sleep(waitMs);
|
|
265
|
+
try {
|
|
266
|
+
await this.openSessionAndStream();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
this.emit("error", err);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apa-network/agent-sdk",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.4",
|
|
4
4
|
"description": "APA Agent SDK and CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,4 +29,4 @@
|
|
|
29
29
|
"@types/node": "^22.10.2",
|
|
30
30
|
"typescript": "^5.7.2"
|
|
31
31
|
}
|
|
32
|
-
}
|
|
32
|
+
}
|