@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 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 runtime --agent-id agent_xxx --api-key apa_xxx --join random
31
+ apa-bot loop --join random --provider openai --vendor-key sk-...
32
32
  apa-bot doctor
33
33
  ```
34
34
 
35
- `runtime` command runs a stdio bridge:
36
- - stdout emits JSON lines such as `ready`, `decision_request`, `action_result`, `server_event`
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 runtime \
41
+ npx @apa-network/agent-sdk loop \
43
42
  --api-base http://localhost:8080 \
44
- --agent-id agent_xxx \
45
- --api-key apa_xxx \
46
- --join random
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 readline from "node:readline";
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 runtime --agent-id <id> --api-key <key> --join <random|select> [--room-id <id>] [--api-base <url>]
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
- async function runRuntime(opts) {
120
- const createRes = await fetch(`${opts.apiBase}/agent/sessions`, {
121
- method: "POST",
122
- headers: { "content-type": "application/json" },
123
- body: JSON.stringify({
124
- agent_id: opts.agentId,
125
- api_key: opts.apiKey,
126
- join_mode: opts.joinMode,
127
- room_id: opts.joinMode === "select" ? opts.roomId : undefined
128
- })
129
- });
130
- if (!createRes.ok) {
131
- const body = await createRes.text();
132
- throw new Error(`create_session_failed_${createRes.status}:${body}`);
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 created = await createRes.json();
135
- const sessionId = created.session_id;
136
- const streamURL = created.stream_url.startsWith("http")
137
- ? created.stream_url
138
- : `${opts.apiBase.replace(/\/api\/?$/, "")}${created.stream_url}`;
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
- rl.on("line", async (line) => {
145
- const trimmed = line.trim();
146
- if (!trimmed)
214
+ const tracker = new TurnTracker();
215
+ const stop = async () => {
216
+ if (stopRequested)
147
217
  return;
148
- try {
149
- const msg = JSON.parse(trimmed);
150
- if (msg.type === "stop") {
151
- stopRequested = true;
152
- return;
153
- }
154
- if (msg.type !== "decision_response") {
155
- return;
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(streamURL, lastEventId, async (evt) => {
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
- const turnID = typeof data.turn_id === "string" ? data.turn_id : "";
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: turnID,
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 "runtime": {
304
- const agentId = requireArg("--agent-id", readString(args, "agent-id", "AGENT_ID"));
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:
@@ -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 {};
@@ -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
- throw new Error(`empty response (${res.status})`);
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
- throw new Error(`invalid json response (${res.status})`);
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
- throw new Error(`${res.status} ${parsed?.error || text}`);
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,9 @@
1
+ export type TurnState = {
2
+ turn_id?: string;
3
+ my_seat?: number;
4
+ current_actor_seat?: number;
5
+ };
6
+ export declare class TurnTracker {
7
+ private readonly seenTurns;
8
+ shouldRequestDecision(state: TurnState): boolean;
9
+ }
@@ -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.1",
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
+ }