@apa-network/agent-sdk 0.2.0-beta.1 → 0.2.0-beta.3
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 +64 -9
- package/dist/cli.js +136 -103
- package/dist/http/client.d.ts +29 -0
- package/dist/http/client.js +47 -3
- package/dist/loop/callback.d.ts +19 -0
- package/dist/loop/callback.js +94 -0
- package/dist/loop/callback.test.d.ts +1 -0
- package/dist/loop/callback.test.js +33 -0
- package/dist/loop/credentials.d.ts +10 -0
- package/dist/loop/credentials.js +61 -0
- package/dist/loop/credentials.test.d.ts +1 -0
- package/dist/loop/credentials.test.js +33 -0
- package/dist/loop/state.d.ts +9 -0
- package/dist/loop/state.js +16 -0
- package/dist/loop/state.test.d.ts +1 -0
- package/dist/loop/state.test.js +14 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -28,22 +28,54 @@ apa-bot register --name BotA --description "test"
|
|
|
28
28
|
apa-bot status --api-key apa_xxx
|
|
29
29
|
apa-bot me --api-key apa_xxx
|
|
30
30
|
apa-bot bind-key --api-key apa_xxx --provider openai --vendor-key sk-... --budget-usd 10
|
|
31
|
-
apa-bot
|
|
31
|
+
apa-bot loop --join random --provider openai --vendor-key sk-...
|
|
32
32
|
apa-bot doctor
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`
|
|
36
|
-
-
|
|
37
|
-
- stdin accepts JSON lines such as `decision_response`, `stop`
|
|
35
|
+
`loop` command runs the full lifecycle (register → topup → match → play) and emits JSON lines:
|
|
36
|
+
- `ready`, `server_event`, `decision_request`, `action_result`, `decision_timeout`
|
|
38
37
|
|
|
39
|
-
Example (no local repository required):
|
|
38
|
+
Example (no local repository required, callback-based decisions):
|
|
40
39
|
|
|
41
40
|
```bash
|
|
42
|
-
npx @apa-network/agent-sdk
|
|
41
|
+
npx @apa-network/agent-sdk loop \
|
|
43
42
|
--api-base http://localhost:8080 \
|
|
44
|
-
--
|
|
45
|
-
--
|
|
46
|
-
--
|
|
43
|
+
--join random \
|
|
44
|
+
--provider openai \
|
|
45
|
+
--vendor-key sk-... \
|
|
46
|
+
--callback-addr 127.0.0.1:8787
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you already have cached credentials, you can omit all identity args:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx @apa-network/agent-sdk loop \
|
|
53
|
+
--api-base http://localhost:8080 \
|
|
54
|
+
--join random \
|
|
55
|
+
--callback-addr 127.0.0.1:8787
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Only one credential is stored locally at a time; new registrations overwrite the previous one.
|
|
59
|
+
Loop reads credentials from the cache and does not accept identity args.
|
|
60
|
+
|
|
61
|
+
If you prefer env-based vendor keys:
|
|
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
|
+
```
|
|
72
|
+
|
|
73
|
+
When a `decision_request` appears, POST to the callback URL:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
curl -sS -X POST http://127.0.0.1:8787/decision \
|
|
77
|
+
-H "content-type: application/json" \
|
|
78
|
+
-d '{"request_id":"req_123","action":"call","thought_log":"safe"}'
|
|
47
79
|
```
|
|
48
80
|
|
|
49
81
|
## Publish (beta)
|
|
@@ -66,3 +98,26 @@ const agent = await client.registerAgent({
|
|
|
66
98
|
});
|
|
67
99
|
console.log(agent);
|
|
68
100
|
```
|
|
101
|
+
|
|
102
|
+
## Credentials Cache
|
|
103
|
+
|
|
104
|
+
Default path:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
~/.config/apa/credentials.json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Format (single credential only):
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"version": 2,
|
|
115
|
+
"credential": {
|
|
116
|
+
"api_base": "http://localhost:8080/api",
|
|
117
|
+
"agent_name": "BotA",
|
|
118
|
+
"agent_id": "agent_xxx",
|
|
119
|
+
"api_key": "apa_xxx",
|
|
120
|
+
"updated_at": "2026-02-05T12:00:00.000Z"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { APAHttpClient } from "./http/client.js";
|
|
2
2
|
import { resolveApiBase, requireArg } from "./utils/config.js";
|
|
3
|
-
import
|
|
3
|
+
import { DecisionCallbackServer } from "./loop/callback.js";
|
|
4
|
+
import { loadCredential } from "./loop/credentials.js";
|
|
5
|
+
import { TurnTracker } from "./loop/state.js";
|
|
4
6
|
function parseArgs(argv) {
|
|
5
7
|
const [command = "help", ...rest] = argv;
|
|
6
8
|
const args = {};
|
|
7
|
-
for (let i = 0; i < rest.length; i
|
|
9
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
8
10
|
const token = rest[i];
|
|
9
11
|
if (!token.startsWith("--")) {
|
|
10
12
|
continue;
|
|
@@ -30,8 +32,11 @@ function readString(args, key, envKey) {
|
|
|
30
32
|
}
|
|
31
33
|
return undefined;
|
|
32
34
|
}
|
|
33
|
-
function readNumber(args, key) {
|
|
35
|
+
function readNumber(args, key, fallback) {
|
|
34
36
|
const raw = args[key];
|
|
37
|
+
if (raw === undefined && fallback !== undefined) {
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
35
40
|
if (typeof raw !== "string") {
|
|
36
41
|
throw new Error(`missing --${key}`);
|
|
37
42
|
}
|
|
@@ -47,7 +52,9 @@ function printHelp() {
|
|
|
47
52
|
apa-bot status --api-key <key> [--api-base <url>]
|
|
48
53
|
apa-bot me --api-key <key> [--api-base <url>]
|
|
49
54
|
apa-bot bind-key --api-key <key> --provider <openai|kimi> --vendor-key <key> --budget-usd <num> [--api-base <url>]
|
|
50
|
-
apa-bot
|
|
55
|
+
apa-bot loop --join <random|select> [--room-id <id>]
|
|
56
|
+
[--provider <openai|kimi>] [--vendor-key <key> | --vendor-key-env <ENV>]
|
|
57
|
+
[--budget-usd <num>] [--callback-addr <host:port>] [--decision-timeout-ms <ms>] [--api-base <url>]
|
|
51
58
|
apa-bot doctor [--api-base <url>]
|
|
52
59
|
|
|
53
60
|
Config priority: CLI args > env (API_BASE) > defaults.`);
|
|
@@ -116,86 +123,110 @@ async function parseSSE(url, lastEventId, onEvent) {
|
|
|
116
123
|
}
|
|
117
124
|
return latestId;
|
|
118
125
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
126
|
+
function pickRoom(rooms, joinMode, roomId) {
|
|
127
|
+
if (rooms.length === 0) {
|
|
128
|
+
throw new Error("no_rooms_available");
|
|
129
|
+
}
|
|
130
|
+
if (joinMode === "select") {
|
|
131
|
+
const match = rooms.find((room) => room.id === roomId);
|
|
132
|
+
if (!match) {
|
|
133
|
+
throw new Error("room_not_found");
|
|
134
|
+
}
|
|
135
|
+
return { id: match.id, min_buyin_cc: match.min_buyin_cc };
|
|
136
|
+
}
|
|
137
|
+
const sorted = [...rooms].sort((a, b) => a.min_buyin_cc - b.min_buyin_cc);
|
|
138
|
+
return { id: sorted[0].id, min_buyin_cc: sorted[0].min_buyin_cc };
|
|
139
|
+
}
|
|
140
|
+
function getVendorKey(args) {
|
|
141
|
+
const direct = readString(args, "vendor-key");
|
|
142
|
+
if (direct) {
|
|
143
|
+
return direct;
|
|
133
144
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
const envName = readString(args, "vendor-key-env");
|
|
146
|
+
if (envName && process.env[envName]) {
|
|
147
|
+
return process.env[envName] || "";
|
|
148
|
+
}
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
async function runLoop(args) {
|
|
152
|
+
const apiBase = resolveApiBase(readString(args, "api-base", "API_BASE"));
|
|
153
|
+
const joinRaw = requireArg("--join", readString(args, "join"));
|
|
154
|
+
const joinMode = joinRaw === "select" ? "select" : "random";
|
|
155
|
+
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
|
+
const callbackAddr = readString(args, "callback-addr") || "127.0.0.1:8787";
|
|
159
|
+
const decisionTimeoutMs = readNumber(args, "decision-timeout-ms", 5000);
|
|
160
|
+
const client = new APAHttpClient({ apiBase });
|
|
161
|
+
const cached = await loadCredential(apiBase, undefined);
|
|
162
|
+
if (!cached) {
|
|
163
|
+
throw new Error("credential_not_found (run apa-bot register first)");
|
|
164
|
+
}
|
|
165
|
+
const agentId = cached.agent_id;
|
|
166
|
+
const apiKey = cached.api_key;
|
|
167
|
+
const status = await client.getAgentStatus(apiKey);
|
|
168
|
+
emit({ type: "agent_status", status });
|
|
169
|
+
if (status?.status === "pending") {
|
|
170
|
+
emit({ type: "claim_required", message: "agent is pending; complete claim before starting loop" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let me = await client.getAgentMe(apiKey);
|
|
174
|
+
let balance = Number(me?.balance_cc ?? 0);
|
|
175
|
+
const rooms = await client.listPublicRooms();
|
|
176
|
+
const pickedRoom = pickRoom(rooms.items || [], joinMode, roomId);
|
|
177
|
+
if (balance < pickedRoom.min_buyin_cc) {
|
|
178
|
+
const vendorKey = getVendorKey(args);
|
|
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})`);
|
|
192
|
+
}
|
|
193
|
+
const callbackServer = new DecisionCallbackServer(callbackAddr);
|
|
194
|
+
const callbackURL = await callbackServer.start();
|
|
195
|
+
const session = await client.createSession({
|
|
196
|
+
agentID: agentId,
|
|
197
|
+
apiKey,
|
|
198
|
+
joinMode: "select",
|
|
199
|
+
roomID: pickedRoom.id
|
|
200
|
+
});
|
|
201
|
+
const sessionId = session.session_id;
|
|
202
|
+
const streamURL = String(session.stream_url || "");
|
|
203
|
+
const base = apiBase.replace(/\/api\/?$/, "");
|
|
204
|
+
const resolvedStreamURL = streamURL.startsWith("http") ? streamURL : `${base}${streamURL}`;
|
|
205
|
+
emit({
|
|
206
|
+
type: "ready",
|
|
207
|
+
agent_id: agentId,
|
|
208
|
+
session_id: sessionId,
|
|
209
|
+
stream_url: resolvedStreamURL,
|
|
210
|
+
callback_url: callbackURL
|
|
211
|
+
});
|
|
139
212
|
let lastEventId = "";
|
|
140
|
-
const pending = new Map();
|
|
141
|
-
const seenTurns = new Set();
|
|
142
|
-
const rl = readline.createInterface({ input: process.stdin });
|
|
143
213
|
let stopRequested = false;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
214
|
+
const tracker = new TurnTracker();
|
|
215
|
+
const stop = async () => {
|
|
216
|
+
if (stopRequested)
|
|
147
217
|
return;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
const decision = msg;
|
|
158
|
-
const pendingTurn = pending.get(decision.request_id);
|
|
159
|
-
if (!pendingTurn) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
pending.delete(decision.request_id);
|
|
163
|
-
const actionRes = await fetch(`${opts.apiBase}/agent/sessions/${sessionId}/actions`, {
|
|
164
|
-
method: "POST",
|
|
165
|
-
headers: { "content-type": "application/json" },
|
|
166
|
-
body: JSON.stringify({
|
|
167
|
-
request_id: decision.request_id,
|
|
168
|
-
turn_id: pendingTurn.turnID,
|
|
169
|
-
action: decision.action,
|
|
170
|
-
amount: decision.amount,
|
|
171
|
-
thought_log: decision.thought_log || ""
|
|
172
|
-
})
|
|
173
|
-
});
|
|
174
|
-
let actionBody = {};
|
|
175
|
-
try {
|
|
176
|
-
actionBody = await actionRes.json();
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
actionBody = {};
|
|
180
|
-
}
|
|
181
|
-
emit({
|
|
182
|
-
type: "action_result",
|
|
183
|
-
request_id: decision.request_id,
|
|
184
|
-
ok: actionRes.ok && actionBody.accepted === true,
|
|
185
|
-
body: actionBody
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
catch (err) {
|
|
189
|
-
emit({
|
|
190
|
-
type: "runtime_error",
|
|
191
|
-
error: err instanceof Error ? err.message : String(err)
|
|
192
|
-
});
|
|
193
|
-
}
|
|
218
|
+
stopRequested = true;
|
|
219
|
+
await callbackServer.stop();
|
|
220
|
+
await client.closeSession(sessionId);
|
|
221
|
+
emit({ type: "stopped", session_id: sessionId });
|
|
222
|
+
};
|
|
223
|
+
process.on("SIGINT", () => {
|
|
224
|
+
emit({ type: "signal", signal: "SIGINT" });
|
|
225
|
+
void stop();
|
|
194
226
|
});
|
|
195
|
-
emit({ type: "ready", session_id: sessionId, stream_url: streamURL });
|
|
196
227
|
while (!stopRequested) {
|
|
197
228
|
try {
|
|
198
|
-
lastEventId = await parseSSE(
|
|
229
|
+
lastEventId = await parseSSE(resolvedStreamURL, lastEventId, async (evt) => {
|
|
199
230
|
let envelope;
|
|
200
231
|
try {
|
|
201
232
|
envelope = JSON.parse(evt.data);
|
|
@@ -206,26 +237,45 @@ async function runRuntime(opts) {
|
|
|
206
237
|
const evType = envelope?.event || evt.event;
|
|
207
238
|
const data = envelope?.data || {};
|
|
208
239
|
emit({ type: "server_event", event: evType, event_id: evt.id || "" });
|
|
240
|
+
if (evType === "session_closed") {
|
|
241
|
+
await stop();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
209
244
|
if (evType !== "state_snapshot") {
|
|
210
245
|
return;
|
|
211
246
|
}
|
|
212
|
-
|
|
213
|
-
const mySeat = Number(data.my_seat ?? -1);
|
|
214
|
-
const actorSeat = Number(data.current_actor_seat ?? -2);
|
|
215
|
-
if (!turnID || mySeat !== actorSeat || seenTurns.has(turnID)) {
|
|
247
|
+
if (!tracker.shouldRequestDecision(data)) {
|
|
216
248
|
return;
|
|
217
249
|
}
|
|
218
|
-
seenTurns.add(turnID);
|
|
219
250
|
const reqID = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000_000)}`;
|
|
220
|
-
pending.set(reqID, { turnID });
|
|
221
251
|
emit({
|
|
222
252
|
type: "decision_request",
|
|
223
253
|
request_id: reqID,
|
|
224
254
|
session_id: sessionId,
|
|
225
|
-
turn_id:
|
|
255
|
+
turn_id: data.turn_id,
|
|
256
|
+
callback_url: callbackURL,
|
|
226
257
|
legal_actions: ["fold", "check", "call", "raise", "bet"],
|
|
227
258
|
state: data
|
|
228
259
|
});
|
|
260
|
+
try {
|
|
261
|
+
const decision = await callbackServer.waitForDecision(reqID, decisionTimeoutMs);
|
|
262
|
+
const result = await client.submitAction({
|
|
263
|
+
sessionID: sessionId,
|
|
264
|
+
requestID: reqID,
|
|
265
|
+
turnID: data.turn_id,
|
|
266
|
+
action: decision.action,
|
|
267
|
+
amount: decision.amount,
|
|
268
|
+
thoughtLog: decision.thought_log
|
|
269
|
+
});
|
|
270
|
+
emit({ type: "action_result", request_id: reqID, ok: true, body: result });
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
emit({
|
|
274
|
+
type: "decision_timeout",
|
|
275
|
+
request_id: reqID,
|
|
276
|
+
error: err instanceof Error ? err.message : String(err)
|
|
277
|
+
});
|
|
278
|
+
}
|
|
229
279
|
});
|
|
230
280
|
}
|
|
231
281
|
catch (err) {
|
|
@@ -233,15 +283,9 @@ async function runRuntime(opts) {
|
|
|
233
283
|
type: "stream_error",
|
|
234
284
|
error: err instanceof Error ? err.message : String(err)
|
|
235
285
|
});
|
|
236
|
-
if (stopRequested) {
|
|
237
|
-
break;
|
|
238
|
-
}
|
|
239
286
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
240
287
|
}
|
|
241
288
|
}
|
|
242
|
-
rl.close();
|
|
243
|
-
await fetch(`${opts.apiBase}/agent/sessions/${sessionId}`, { method: "DELETE" }).catch(() => undefined);
|
|
244
|
-
emit({ type: "stopped", session_id: sessionId });
|
|
245
289
|
}
|
|
246
290
|
async function run() {
|
|
247
291
|
const { command, args } = parseArgs(process.argv.slice(2));
|
|
@@ -300,19 +344,8 @@ async function run() {
|
|
|
300
344
|
console.log(JSON.stringify(report, null, 2));
|
|
301
345
|
return;
|
|
302
346
|
}
|
|
303
|
-
case "
|
|
304
|
-
|
|
305
|
-
const apiKey = requireArg("--api-key", readString(args, "api-key", "APA_API_KEY"));
|
|
306
|
-
const joinRaw = requireArg("--join", readString(args, "join"));
|
|
307
|
-
const joinMode = joinRaw === "select" ? "select" : "random";
|
|
308
|
-
const roomId = joinMode === "select" ? requireArg("--room-id", readString(args, "room-id")) : undefined;
|
|
309
|
-
await runRuntime({
|
|
310
|
-
apiBase,
|
|
311
|
-
agentId,
|
|
312
|
-
apiKey,
|
|
313
|
-
joinMode,
|
|
314
|
-
roomId
|
|
315
|
-
});
|
|
347
|
+
case "loop": {
|
|
348
|
+
await runLoop(args);
|
|
316
349
|
return;
|
|
317
350
|
}
|
|
318
351
|
default:
|
package/dist/http/client.d.ts
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
type HttpClientOptions = {
|
|
2
2
|
apiBase?: string;
|
|
3
3
|
};
|
|
4
|
+
export type APAClientError = Error & {
|
|
5
|
+
status?: number;
|
|
6
|
+
code?: string;
|
|
7
|
+
body?: unknown;
|
|
8
|
+
};
|
|
9
|
+
type CreateSessionInput = {
|
|
10
|
+
agentID: string;
|
|
11
|
+
apiKey: string;
|
|
12
|
+
joinMode: "random" | "select";
|
|
13
|
+
roomID?: string;
|
|
14
|
+
};
|
|
15
|
+
type SubmitActionInput = {
|
|
16
|
+
sessionID: string;
|
|
17
|
+
requestID: string;
|
|
18
|
+
turnID: string;
|
|
19
|
+
action: "fold" | "check" | "call" | "raise" | "bet";
|
|
20
|
+
amount?: number;
|
|
21
|
+
thoughtLog?: string;
|
|
22
|
+
};
|
|
4
23
|
export declare class APAHttpClient {
|
|
5
24
|
private readonly apiBase;
|
|
6
25
|
constructor(opts?: HttpClientOptions);
|
|
@@ -16,6 +35,16 @@ export declare class APAHttpClient {
|
|
|
16
35
|
vendorKey: string;
|
|
17
36
|
budgetUsd: number;
|
|
18
37
|
}): Promise<any>;
|
|
38
|
+
listPublicRooms(): Promise<{
|
|
39
|
+
items: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
min_buyin_cc: number;
|
|
42
|
+
name: string;
|
|
43
|
+
}>;
|
|
44
|
+
}>;
|
|
45
|
+
createSession(input: CreateSessionInput): Promise<any>;
|
|
46
|
+
submitAction(input: SubmitActionInput): Promise<any>;
|
|
47
|
+
closeSession(sessionID: string): Promise<void>;
|
|
19
48
|
healthz(): Promise<any>;
|
|
20
49
|
}
|
|
21
50
|
export {};
|
package/dist/http/client.js
CHANGED
|
@@ -2,17 +2,27 @@ import { resolveApiBase } from "../utils/config.js";
|
|
|
2
2
|
async function parseJson(res) {
|
|
3
3
|
const text = await res.text();
|
|
4
4
|
if (!text) {
|
|
5
|
-
|
|
5
|
+
const err = new Error(`empty response (${res.status})`);
|
|
6
|
+
err.status = res.status;
|
|
7
|
+
throw err;
|
|
6
8
|
}
|
|
7
9
|
let parsed;
|
|
8
10
|
try {
|
|
9
11
|
parsed = JSON.parse(text);
|
|
10
12
|
}
|
|
11
13
|
catch {
|
|
12
|
-
|
|
14
|
+
const err = new Error(`invalid json response (${res.status})`);
|
|
15
|
+
err.status = res.status;
|
|
16
|
+
err.body = text;
|
|
17
|
+
throw err;
|
|
13
18
|
}
|
|
14
19
|
if (!res.ok) {
|
|
15
|
-
|
|
20
|
+
const p = parsed;
|
|
21
|
+
const err = new Error(`${res.status} ${p?.error || text}`);
|
|
22
|
+
err.status = res.status;
|
|
23
|
+
err.code = p?.error || "request_failed";
|
|
24
|
+
err.body = parsed;
|
|
25
|
+
throw err;
|
|
16
26
|
}
|
|
17
27
|
return parsed;
|
|
18
28
|
}
|
|
@@ -56,6 +66,40 @@ export class APAHttpClient {
|
|
|
56
66
|
});
|
|
57
67
|
return parseJson(res);
|
|
58
68
|
}
|
|
69
|
+
async listPublicRooms() {
|
|
70
|
+
const res = await fetch(`${this.apiBase}/public/rooms`);
|
|
71
|
+
return parseJson(res);
|
|
72
|
+
}
|
|
73
|
+
async createSession(input) {
|
|
74
|
+
const res = await fetch(`${this.apiBase}/agent/sessions`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
agent_id: input.agentID,
|
|
79
|
+
api_key: input.apiKey,
|
|
80
|
+
join_mode: input.joinMode,
|
|
81
|
+
room_id: input.joinMode === "select" ? input.roomID : undefined
|
|
82
|
+
})
|
|
83
|
+
});
|
|
84
|
+
return parseJson(res);
|
|
85
|
+
}
|
|
86
|
+
async submitAction(input) {
|
|
87
|
+
const res = await fetch(`${this.apiBase}/agent/sessions/${input.sessionID}/actions`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json" },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
request_id: input.requestID,
|
|
92
|
+
turn_id: input.turnID,
|
|
93
|
+
action: input.action,
|
|
94
|
+
amount: input.amount,
|
|
95
|
+
thought_log: input.thoughtLog || ""
|
|
96
|
+
})
|
|
97
|
+
});
|
|
98
|
+
return parseJson(res);
|
|
99
|
+
}
|
|
100
|
+
async closeSession(sessionID) {
|
|
101
|
+
await fetch(`${this.apiBase}/agent/sessions/${sessionID}`, { method: "DELETE" });
|
|
102
|
+
}
|
|
59
103
|
async healthz() {
|
|
60
104
|
const base = this.apiBase.replace(/\/api\/?$/, "");
|
|
61
105
|
const res = await fetch(`${base}/healthz`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type DecisionAction = "fold" | "check" | "call" | "raise" | "bet";
|
|
2
|
+
export type DecisionPayload = {
|
|
3
|
+
request_id: string;
|
|
4
|
+
action: DecisionAction;
|
|
5
|
+
amount?: number;
|
|
6
|
+
thought_log?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare class DecisionCallbackServer {
|
|
9
|
+
private readonly addr;
|
|
10
|
+
private readonly decisions;
|
|
11
|
+
private server;
|
|
12
|
+
private callbackURL;
|
|
13
|
+
constructor(addr: string);
|
|
14
|
+
start(): Promise<string>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
waitForDecision(requestID: string, timeoutMs: number): Promise<DecisionPayload>;
|
|
17
|
+
private handleRequest;
|
|
18
|
+
private reply;
|
|
19
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { URL } from "node:url";
|
|
3
|
+
export class DecisionCallbackServer {
|
|
4
|
+
addr;
|
|
5
|
+
decisions = new Map();
|
|
6
|
+
server = null;
|
|
7
|
+
callbackURL = "";
|
|
8
|
+
constructor(addr) {
|
|
9
|
+
this.addr = addr;
|
|
10
|
+
}
|
|
11
|
+
async start() {
|
|
12
|
+
if (this.server) {
|
|
13
|
+
return this.callbackURL;
|
|
14
|
+
}
|
|
15
|
+
const [host, portRaw] = this.addr.split(":");
|
|
16
|
+
const port = Number(portRaw);
|
|
17
|
+
if (!host || !Number.isFinite(port) || port <= 0) {
|
|
18
|
+
throw new Error(`invalid callback addr: ${this.addr}`);
|
|
19
|
+
}
|
|
20
|
+
this.server = http.createServer(this.handleRequest.bind(this));
|
|
21
|
+
await new Promise((resolve, reject) => {
|
|
22
|
+
this.server?.once("error", reject);
|
|
23
|
+
this.server?.listen(port, host, () => resolve());
|
|
24
|
+
});
|
|
25
|
+
this.callbackURL = `http://${host}:${port}/decision`;
|
|
26
|
+
return this.callbackURL;
|
|
27
|
+
}
|
|
28
|
+
async stop() {
|
|
29
|
+
const entries = [...this.decisions.values()];
|
|
30
|
+
this.decisions.clear();
|
|
31
|
+
for (const pending of entries) {
|
|
32
|
+
clearTimeout(pending.timeout);
|
|
33
|
+
pending.reject(new Error("callback_server_stopped"));
|
|
34
|
+
}
|
|
35
|
+
if (!this.server) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const s = this.server;
|
|
39
|
+
this.server = null;
|
|
40
|
+
await new Promise((resolve) => s.close(() => resolve()));
|
|
41
|
+
}
|
|
42
|
+
waitForDecision(requestID, timeoutMs) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const timeout = setTimeout(() => {
|
|
45
|
+
this.decisions.delete(requestID);
|
|
46
|
+
reject(new Error("decision_timeout"));
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
this.decisions.set(requestID, { resolve, reject, timeout });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async handleRequest(req, res) {
|
|
52
|
+
const method = req.method || "";
|
|
53
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
54
|
+
if (method === "GET" && url.pathname === "/healthz") {
|
|
55
|
+
this.reply(res, 200, { ok: true });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (method !== "POST" || url.pathname !== "/decision") {
|
|
59
|
+
this.reply(res, 404, { error: "not_found" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let body = "";
|
|
63
|
+
req.setEncoding("utf8");
|
|
64
|
+
for await (const chunk of req) {
|
|
65
|
+
body += chunk;
|
|
66
|
+
}
|
|
67
|
+
let payload = null;
|
|
68
|
+
try {
|
|
69
|
+
payload = JSON.parse(body);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
this.reply(res, 400, { error: "invalid_json" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!payload || typeof payload.request_id !== "string" || typeof payload.action !== "string") {
|
|
76
|
+
this.reply(res, 400, { error: "invalid_payload" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const pending = this.decisions.get(payload.request_id);
|
|
80
|
+
if (!pending) {
|
|
81
|
+
this.reply(res, 409, { error: "request_not_pending" });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.decisions.delete(payload.request_id);
|
|
85
|
+
clearTimeout(pending.timeout);
|
|
86
|
+
pending.resolve(payload);
|
|
87
|
+
this.reply(res, 200, { ok: true });
|
|
88
|
+
}
|
|
89
|
+
reply(res, status, payload) {
|
|
90
|
+
res.statusCode = status;
|
|
91
|
+
res.setHeader("content-type", "application/json");
|
|
92
|
+
res.end(`${JSON.stringify(payload)}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { DecisionCallbackServer } from "./callback.js";
|
|
4
|
+
test("callback server receives decision and resolves pending request", async (t) => {
|
|
5
|
+
const server = new DecisionCallbackServer("127.0.0.1:18787");
|
|
6
|
+
let callbackURL = "";
|
|
7
|
+
try {
|
|
8
|
+
callbackURL = await server.start();
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
const code = err && typeof err === "object" ? err.code : "";
|
|
12
|
+
if (code === "EPERM" || code === "EACCES") {
|
|
13
|
+
t.skip("listen not permitted in this environment");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
const decisionPromise = server.waitForDecision("req_1", 2000);
|
|
19
|
+
const res = await fetch(callbackURL, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "content-type": "application/json" },
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
request_id: "req_1",
|
|
24
|
+
action: "call",
|
|
25
|
+
thought_log: "ok"
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
assert.equal(res.status, 200);
|
|
29
|
+
const decision = await decisionPromise;
|
|
30
|
+
assert.equal(decision.request_id, "req_1");
|
|
31
|
+
assert.equal(decision.action, "call");
|
|
32
|
+
await server.stop();
|
|
33
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type AgentCredential = {
|
|
2
|
+
api_base: string;
|
|
3
|
+
agent_name: string;
|
|
4
|
+
agent_id: string;
|
|
5
|
+
api_key: string;
|
|
6
|
+
updated_at: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function defaultCredentialPath(): string;
|
|
9
|
+
export declare function loadCredential(apiBase: string, _agentName: string | undefined, filePath?: string): Promise<AgentCredential | null>;
|
|
10
|
+
export declare function saveCredential(record: Omit<AgentCredential, "updated_at">, filePath?: string): Promise<void>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const STORE_VERSION = 2;
|
|
5
|
+
export function defaultCredentialPath() {
|
|
6
|
+
return path.join(os.homedir(), ".config", "apa", "credentials.json");
|
|
7
|
+
}
|
|
8
|
+
export async function loadCredential(apiBase, _agentName, filePath = defaultCredentialPath()) {
|
|
9
|
+
const store = await readStore(filePath);
|
|
10
|
+
if (!store.credential) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
if (store.credential.api_base !== apiBase) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return store.credential;
|
|
17
|
+
}
|
|
18
|
+
export async function saveCredential(record, filePath = defaultCredentialPath()) {
|
|
19
|
+
const store = await readStore(filePath);
|
|
20
|
+
store.credential = {
|
|
21
|
+
...record,
|
|
22
|
+
updated_at: new Date().toISOString()
|
|
23
|
+
};
|
|
24
|
+
await writeStore(store, filePath);
|
|
25
|
+
}
|
|
26
|
+
async function readStore(filePath) {
|
|
27
|
+
let raw = "";
|
|
28
|
+
try {
|
|
29
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (isENOENT(err)) {
|
|
33
|
+
return { version: STORE_VERSION };
|
|
34
|
+
}
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
if (!raw.trim()) {
|
|
38
|
+
return { version: STORE_VERSION };
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { version: STORE_VERSION };
|
|
46
|
+
}
|
|
47
|
+
if (!parsed || typeof parsed !== "object") {
|
|
48
|
+
return { version: STORE_VERSION };
|
|
49
|
+
}
|
|
50
|
+
if (parsed.credential && typeof parsed.credential === "object") {
|
|
51
|
+
return { version: STORE_VERSION, credential: parsed.credential };
|
|
52
|
+
}
|
|
53
|
+
return { version: STORE_VERSION };
|
|
54
|
+
}
|
|
55
|
+
async function writeStore(store, filePath) {
|
|
56
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
57
|
+
await fs.writeFile(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
58
|
+
}
|
|
59
|
+
function isENOENT(err) {
|
|
60
|
+
return Boolean(err && typeof err === "object" && err.code === "ENOENT");
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import { loadCredential, saveCredential } from "./credentials.js";
|
|
7
|
+
test("saveCredential and loadCredential roundtrip", async () => {
|
|
8
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "apa-sdk-creds-"));
|
|
9
|
+
const filePath = path.join(dir, "credentials.json");
|
|
10
|
+
await saveCredential({
|
|
11
|
+
api_base: "http://localhost:8080/api",
|
|
12
|
+
agent_name: "BotA",
|
|
13
|
+
agent_id: "agent_1",
|
|
14
|
+
api_key: "apa_1"
|
|
15
|
+
}, filePath);
|
|
16
|
+
const loaded = await loadCredential("http://localhost:8080/api", "BotA", filePath);
|
|
17
|
+
assert.ok(loaded);
|
|
18
|
+
assert.equal(loaded?.agent_id, "agent_1");
|
|
19
|
+
assert.equal(loaded?.api_key, "apa_1");
|
|
20
|
+
});
|
|
21
|
+
test("loadCredential without agentName returns single match for api base", async () => {
|
|
22
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "apa-sdk-creds-"));
|
|
23
|
+
const filePath = path.join(dir, "credentials.json");
|
|
24
|
+
await saveCredential({
|
|
25
|
+
api_base: "http://localhost:8080/api",
|
|
26
|
+
agent_name: "BotA",
|
|
27
|
+
agent_id: "agent_1",
|
|
28
|
+
api_key: "apa_1"
|
|
29
|
+
}, filePath);
|
|
30
|
+
const loaded = await loadCredential("http://localhost:8080/api", undefined, filePath);
|
|
31
|
+
assert.ok(loaded);
|
|
32
|
+
assert.equal(loaded?.agent_name, "BotA");
|
|
33
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class TurnTracker {
|
|
2
|
+
seenTurns = new Set();
|
|
3
|
+
shouldRequestDecision(state) {
|
|
4
|
+
const turnID = typeof state.turn_id === "string" ? state.turn_id : "";
|
|
5
|
+
const mySeat = Number(state.my_seat ?? -1);
|
|
6
|
+
const actorSeat = Number(state.current_actor_seat ?? -2);
|
|
7
|
+
if (!turnID || mySeat !== actorSeat) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (this.seenTurns.has(turnID)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
this.seenTurns.add(turnID);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { TurnTracker } from "./state.js";
|
|
4
|
+
test("TurnTracker requests exactly once for my new turn", () => {
|
|
5
|
+
const tracker = new TurnTracker();
|
|
6
|
+
const state = { turn_id: "turn_1", my_seat: 0, current_actor_seat: 0 };
|
|
7
|
+
assert.equal(tracker.shouldRequestDecision(state), true);
|
|
8
|
+
assert.equal(tracker.shouldRequestDecision(state), false);
|
|
9
|
+
});
|
|
10
|
+
test("TurnTracker ignores opponent turn", () => {
|
|
11
|
+
const tracker = new TurnTracker();
|
|
12
|
+
const state = { turn_id: "turn_2", my_seat: 0, current_actor_seat: 1 };
|
|
13
|
+
assert.equal(tracker.shouldRequestDecision(state), false);
|
|
14
|
+
});
|
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.3",
|
|
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
|
+
}
|