@whitesands/agent-player 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Register a tool from a name, description, Zod schema, and callback with `bindDynamic`.
8
+ - `start()` requires `playerId`, the character UUID from the web app. One platform key serves the game, and each session names the character to play.
9
+
10
+ ## 1.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - The SDK connects to `wss://api.whitesandsint.com`. The platform URL is no longer read from the environment.
15
+
3
16
  ## 1.0.0
4
17
 
5
18
  ### Major Changes
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # WSI Agent Player
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@whitesands/agent-player.svg)](https://www.npmjs.com/package/@whitesands/agent-player)
4
- [![Build Status](https://github.com/White-Sands-Interactive/WSI-Node-Framework/actions/workflows/ci.yml/badge.svg)](https://github.com/White-Sands-Interactive/WSI-Node-Framework/actions/workflows/ci.yml)
4
+ [![Build Status](https://github.com/White-Sands-Interactive/WSI-Node/actions/workflows/ci.yml/badge.svg)](https://github.com/White-Sands-Interactive/WSI-Node/actions/workflows/ci.yml)
5
5
 
6
- The library starts a remote game agent from a player class. The game calls `start` and `turn`. The SDK publishes that class as tools. The model runs on the [platform](https://github.com/White-Sands-Interactive/Platform), so the game process never holds the model key.
6
+ The library starts a remote game agent from a player class. The game calls `start` and `turn`. The SDK publishes that class as tools. The model runs on the platform, so the game process never holds the model key.
7
7
 
8
8
  The library also provides:
9
9
 
@@ -40,20 +40,20 @@ Use the same Zod 3 the SDK depends on when you declare argument schemas. Zod 4 o
40
40
 
41
41
  ## Usage
42
42
 
43
- Set the platform key, then start one agent per character. `turn()` grants that character one chance to act and resolves with what they did.
43
+ Set the platform key, then start one agent per character. The key belongs to the game. `playerId` is the character id from the Players page, and every `start()` sends it. `turn()` grants that character one chance to act and resolves with what they did.
44
44
 
45
45
  ```sh
46
46
  export WSI_PLATFORM_KEY=pk_...
47
- export WSI_PLATFORM_URL=ws://127.0.0.1:8787
48
47
  ```
49
48
 
50
- `WSI_PLATFORM_URL` defaults to `ws://127.0.0.1:8787` when it is unset.
49
+ The SDK connects to `wss://api.whitesandsint.com`.
51
50
 
52
51
  ```ts
53
52
  import { AgentServer } from "@whitesands/agent-player";
54
53
 
55
54
  const agents = new AgentServer();
56
55
  const ada = await agents.start(new Villager(world, "Ada"), {
56
+ playerId: "3f6c2d1e-8b7a-4c5d-9e0f-1a2b3c4d5e6f",
57
57
  name: "Ada",
58
58
  goal: "Stand on the tree and chop it",
59
59
  });
@@ -62,7 +62,7 @@ const result = await ada.turn();
62
62
  // { action: "move", args: { dx: 1, dy: 0 }, result, attempts: 1, usage }
63
63
  ```
64
64
 
65
- `new AgentServer({ platformKey, platformUrl })` overrides the environment variables.
65
+ `new AgentServer({ platformKey })` overrides `WSI_PLATFORM_KEY`.
66
66
 
67
67
  ### The player class
68
68
 
@@ -128,6 +128,28 @@ class Villager {
128
128
  }
129
129
  ```
130
130
 
131
+ ### Tools from data
132
+
133
+ `bindDynamic` registers one tool when the action is data rather than a method. Call it on the player before `start()`. `see` is allowed, including when the player has no `see()` method. `wait`, an empty name, a name starting with `_`, and a missing callback are rejected. An empty description becomes `Action: <name>`. Registering the same name again replaces that tool, including a reflected method of the same name.
134
+
135
+ Arguments use the same Zod shape as `static agent`. A schema failure throws before the callback runs and uses the one retry. Extra keys are ignored.
136
+
137
+ ```ts
138
+ import { AgentServer, bindDynamic } from "@whitesands/agent-player";
139
+ import { z } from "zod";
140
+
141
+ const scout = { n: 1 };
142
+ const playerId = "3f6c2d1e-8b7a-4c5d-9e0f-1a2b3c4d5e6f";
143
+ bindDynamic(scout, "see", "", undefined, () => ({ n: scout.n }));
144
+ bindDynamic(scout, "add", "Add dx", { dx: z.number().int() }, (args) => {
145
+ scout.n += args.dx as number;
146
+ return { n: scout.n };
147
+ });
148
+
149
+ const agents = new AgentServer();
150
+ const agent = await agents.start(scout, { playerId });
151
+ ```
152
+
131
153
  ### Turns
132
154
 
133
155
  `turn()` does not choose the action. It unblocks that session's `wait`. The platform reads `see()`, calls one action, and reports back.
@@ -177,11 +199,19 @@ try {
177
199
 
178
200
  ### Several characters
179
201
 
180
- One `AgentServer` hosts every character. Each `start()` is its own session.
202
+ One `AgentServer` and one platform key host every character in the game. Each `start()` is its own session and names that character with `playerId`. The id is a UUID, and it stays the same when the character is renamed. A missing id, a value that is not a UUID, or an id from another game is rejected and no session is created.
181
203
 
182
204
  ```ts
183
- const ada = await agents.start(new Villager(world, "Ada"), { name: "Ada", goal: "Chop the tree" });
184
- const bea = await agents.start(new Villager(world, "Bea"), { name: "Bea", goal: "Carry wood home" });
205
+ const ada = await agents.start(new Villager(world, "Ada"), {
206
+ playerId: "3f6c2d1e-8b7a-4c5d-9e0f-1a2b3c4d5e6f",
207
+ name: "Ada",
208
+ goal: "Chop the tree",
209
+ });
210
+ const bea = await agents.start(new Villager(world, "Bea"), {
211
+ playerId: "6b1e2a30-0c1d-4e5f-8a9b-0c1d2e3f4a5b",
212
+ name: "Bea",
213
+ goal: "Carry wood home",
214
+ });
185
215
 
186
216
  await ada.turn();
187
217
  await bea.turn();
@@ -235,6 +265,7 @@ const agents = new AgentServer({
235
265
 
236
266
  ```ts
237
267
  await agents.start(ferry, {
268
+ playerId: "3f6c2d1e-8b7a-4c5d-9e0f-1a2b3c4d5e6f",
238
269
  name: "Ferryman",
239
270
  goal: "Get the wolf, the goat, and the cabbage onto the far bank.",
240
271
  });
@@ -242,7 +273,7 @@ await agents.start(ferry, {
242
273
 
243
274
  ### Testing without a platform
244
275
 
245
- `@whitesands/agent-player/testing` runs the same wait, act, and retry loop in memory. The script is the list of tool calls, in order. No key and no socket.
276
+ `@whitesands/agent-player/testing` runs the same wait, act, and retry loop in memory. The script is the list of tool calls, in order. No key and no socket. `start()` still requires `playerId`.
246
277
 
247
278
  ```ts
248
279
  import { AgentServer } from "@whitesands/agent-player";
@@ -283,7 +314,7 @@ const agents = new AgentServer({
283
314
 
284
315
  ### Protocol
285
316
 
286
- `@whitesands/agent-player/protocol` exports the wire types and `PROTOCOL_VERSION`. The platform's copy is `internal/protocol` in [White-Sands-Interactive/Platform](https://github.com/White-Sands-Interactive/Platform). A change to the wire format has to land in both places. Game code uses the main entry and does not import the protocol.
317
+ `@whitesands/agent-player/protocol` exports the wire types and `PROTOCOL_VERSION`. Game code uses the main entry and does not import the protocol.
287
318
 
288
319
  ## This repository
289
320
 
@@ -303,12 +334,10 @@ npm run courier
303
334
  npm run ferry
304
335
  ```
305
336
 
306
- Each example replays a script. Add `-- --remote` to use a platform that is already serving:
337
+ Each example replays a script. Forest and ferry can also call the hosted platform:
307
338
 
308
339
  ```sh
309
- ./platform create-key demo
310
- MODEL_API_KEY=... MODEL_BASE_URL=... MODEL_NAME=... ./platform serve
311
340
  PLATFORM_KEY=pk_... npm run forest -- --remote
312
341
  ```
313
342
 
314
- `./platform` is the Go server from the Platform repository. Releases are recorded in [CHANGELOG.md](CHANGELOG.md).
343
+ Releases are recorded in [CHANGELOG.md](CHANGELOG.md).
package/dist/index.cjs CHANGED
@@ -35,7 +35,8 @@ __export(src_exports, {
35
35
  PlatformAuthError: () => PlatformAuthError,
36
36
  ProtocolVersionError: () => ProtocolVersionError,
37
37
  TurnFailedError: () => TurnFailedError,
38
- TurnTimeoutError: () => TurnTimeoutError
38
+ TurnTimeoutError: () => TurnTimeoutError,
39
+ bindDynamic: () => bindDynamic
39
40
  });
40
41
  module.exports = __toCommonJS(src_exports);
41
42
 
@@ -158,7 +159,7 @@ var import_ws = __toESM(require("ws"), 1);
158
159
 
159
160
  // src/protocol.ts
160
161
  var PROTOCOL_VERSION = 1;
161
- var SDK_VERSION = "1.0.0";
162
+ var SDK_VERSION = "1.2.0";
162
163
  var SESSION_RESUME_GRACE_MS = 3e4;
163
164
  var DEFAULT_AGENT_INSTRUCTIONS = [
164
165
  "You are a remote game agent. Tools are provided by the game.",
@@ -167,6 +168,17 @@ var DEFAULT_AGENT_INSTRUCTIONS = [
167
168
  "The view's done list is what this character has already called in this session, oldest first. Use it when choosing the next action.",
168
169
  "If an action tool returns an error, retry once with a corrected action. A second failure ends the turn as failed."
169
170
  ].join("\n");
171
+ var PLAYER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
172
+ function requirePlayerId(value) {
173
+ const id = value?.trim() ?? "";
174
+ if (id.length === 0) {
175
+ throw new Error("playerId is required. Copy the character ID from the web app");
176
+ }
177
+ if (!PLAYER_ID.test(id)) {
178
+ throw new Error(`playerId "${id}" is not a character ID. Copy the ID from the character in the web app`);
179
+ }
180
+ return id;
181
+ }
170
182
  function assertNever(value) {
171
183
  throw new Error(`Unhandled protocol message: ${JSON.stringify(value)}`);
172
184
  }
@@ -279,6 +291,14 @@ var PlatformConnection = class {
279
291
  });
280
292
  });
281
293
  }
294
+ /** Reject a start, stop, or resume wait. No-op when nothing is waiting. */
295
+ failSessionEvent(sessionId, kind, error) {
296
+ const key = `session:${sessionId}:${kind}`;
297
+ const waiter = this.pending.get(key);
298
+ if (!waiter) return;
299
+ this.pending.delete(key);
300
+ waiter.reject(error);
301
+ }
282
302
  createTransport(sessionId) {
283
303
  return new SessionMuxTransport(sessionId, (envelope) => this.send(envelope));
284
304
  }
@@ -482,6 +502,7 @@ var PlatformConnection = class {
482
502
  var import_zod = require("zod");
483
503
  var import_zod_to_json_schema = require("zod-to-json-schema");
484
504
  var SKIP = /* @__PURE__ */ new Set(["constructor"]);
505
+ var dynamicTools = /* @__PURE__ */ new WeakMap();
485
506
  var SEE_DESCRIPTION = "Read the current game view for this player. Prefer wait, which returns this after the game grants a turn.";
486
507
  var WAIT_DESCRIPTION = "Block until the game calls turn() for this session, then return the current see() view.";
487
508
  function getParamNames(fn) {
@@ -525,6 +546,56 @@ function formatZodError(error) {
525
546
  return `${path}: ${issue.message}`;
526
547
  }).join("; ");
527
548
  }
549
+ function rejectDynamicName(name) {
550
+ if (name.length === 0 || name === "wait" || name.startsWith("_")) {
551
+ throw new Error(`cannot publish action "${name}"`);
552
+ }
553
+ }
554
+ function dynamicBindings(player) {
555
+ return dynamicTools.get(player) ?? /* @__PURE__ */ new Map();
556
+ }
557
+ function bindDynamic(player, name, description, args, callback) {
558
+ rejectDynamicName(name);
559
+ if (typeof callback !== "function") {
560
+ throw new Error(`cannot publish action "${name}"`);
561
+ }
562
+ const shape = args && Object.keys(args).length > 0 ? { ...args } : void 0;
563
+ let tools = dynamicTools.get(player);
564
+ if (!tools) {
565
+ tools = /* @__PURE__ */ new Map();
566
+ dynamicTools.set(player, tools);
567
+ }
568
+ tools.set(name, {
569
+ name,
570
+ description: description.length > 0 ? description : `Action: ${name}`,
571
+ ...shape ? { args: shape } : {},
572
+ callback
573
+ });
574
+ }
575
+ function buildDynamicTool(binding) {
576
+ const shape = binding.args;
577
+ const paramNames = shape ? Object.keys(shape) : [];
578
+ const argsSchema = shape ? import_zod.z.object(shape) : void 0;
579
+ const inputSchema = argsSchema ? toJsonSchema(argsSchema) : { type: "object", properties: {} };
580
+ return {
581
+ name: binding.name,
582
+ description: binding.description,
583
+ paramNames,
584
+ ...argsSchema ? { argsSchema } : {},
585
+ descriptor: { name: binding.name, description: binding.description, inputSchema },
586
+ async invoke(args) {
587
+ let parsed = {};
588
+ if (argsSchema) {
589
+ const result = argsSchema.safeParse(args ?? {});
590
+ if (!result.success) {
591
+ throw new Error(formatZodError(result.error));
592
+ }
593
+ parsed = result.data;
594
+ }
595
+ return await Promise.resolve(binding.callback(parsed));
596
+ }
597
+ };
598
+ }
528
599
  function buildTool(instance, name, description, spec) {
529
600
  const fn = instance[name];
530
601
  if (typeof fn !== "function") {
@@ -560,24 +631,31 @@ function buildTool(instance, name, description, spec) {
560
631
  }
561
632
  function reflectPlayer(instance) {
562
633
  const names = collectMethodNames(instance);
563
- if (!names.includes("see")) {
634
+ const dynamic = dynamicBindings(instance);
635
+ if (!names.includes("see") && !dynamic.has("see")) {
564
636
  throw new Error("Player must implement a public see() method");
565
637
  }
566
638
  const metadata = readMetadata(instance);
567
639
  const declared = metadata?.actions ?? {};
568
640
  const className = instance.constructor?.name || "Player";
569
641
  for (const name of Object.keys(declared)) {
570
- if (!names.includes(name) || name === "see") {
642
+ if (name === "see" || !names.includes(name) && !dynamic.has(name)) {
571
643
  throw new Error(`Declared action "${name}" is not a public method on ${className}`);
572
644
  }
573
645
  }
574
- const see = buildTool(instance, "see", SEE_DESCRIPTION);
575
- const actionNames = names.filter((name) => name !== "see");
576
- const actions = actionNames.map((name) => {
646
+ const dynamicSee = dynamic.get("see");
647
+ const see = dynamicSee ? buildDynamicTool(dynamicSee) : buildTool(instance, "see", SEE_DESCRIPTION);
648
+ const actions = [];
649
+ for (const name of names) {
650
+ if (name === "see" || dynamic.has(name)) continue;
577
651
  const spec = declared[name];
578
652
  const description = spec?.description ?? `Action: ${name}`;
579
- return buildTool(instance, name, description, spec);
580
- });
653
+ actions.push(buildTool(instance, name, description, spec));
654
+ }
655
+ for (const [name, binding] of dynamic) {
656
+ if (name === "see") continue;
657
+ actions.push(buildDynamicTool(binding));
658
+ }
581
659
  const wait = {
582
660
  name: "wait",
583
661
  description: WAIT_DESCRIPTION,
@@ -774,7 +852,7 @@ var TurnGate = class {
774
852
 
775
853
  // src/agent-server.ts
776
854
  var DEFAULT_TURN_TIMEOUT_MS = 6e4;
777
- var DEFAULT_PLATFORM_URL = "ws://127.0.0.1:8787";
855
+ var PLATFORM_URL = "wss://api.whitesandsint.com";
778
856
  var AgentServer = class {
779
857
  options;
780
858
  platformKey;
@@ -794,7 +872,7 @@ var AgentServer = class {
794
872
  }
795
873
  this.options = options;
796
874
  this.platformKey = platformKey || "inline";
797
- this.platformUrl = options.platformUrl ?? process.env.WSI_PLATFORM_URL ?? DEFAULT_PLATFORM_URL;
875
+ this.platformUrl = options.platformUrl ?? PLATFORM_URL;
798
876
  this.logger = options.logger;
799
877
  this.inline = options.inline;
800
878
  if (options.onEvent) this.listeners.add(options.onEvent);
@@ -826,6 +904,7 @@ var AgentServer = class {
826
904
  * `turn()` on that handle lets the remote model act once.
827
905
  */
828
906
  async start(instance, options) {
907
+ const playerId = requirePlayerId(options.playerId);
829
908
  await this.connect();
830
909
  const reflected = reflectPlayer(instance);
831
910
  const sessionId = (0, import_node_crypto2.randomUUID)();
@@ -857,41 +936,54 @@ var AgentServer = class {
857
936
  gate.completeTurn(result);
858
937
  };
859
938
  const onEvent = (event) => this.emit(event);
860
- if (this.inline) {
861
- const [serverTransport, clientTransport] = import_inMemory.InMemoryTransport.createLinkedPair();
862
- await server.connect(serverTransport);
863
- record.transport = serverTransport;
864
- this.sessions.set(sessionId, record);
865
- await this.inline.startSession({
866
- sessionId,
867
- sessionToken,
868
- name,
869
- tools: reflected.tools,
870
- instructions,
871
- ...options?.goal ? { goal: options.goal } : {},
872
- ...this.logger ? { logger: this.logger } : {},
873
- transport: clientTransport,
874
- onTurnResult,
875
- onEvent
876
- });
877
- } else {
878
- if (!this.connection) throw new Error("Not connected");
879
- const mux = this.connection.createTransport(sessionId);
880
- await server.connect(mux);
881
- record.transport = mux;
882
- record.mux = mux;
883
- this.sessions.set(sessionId, record);
884
- const started = this.connection.waitForSessionEvent(sessionId, "start");
885
- this.connection.send({
886
- type: "start_session",
887
- sessionId,
888
- sessionToken,
889
- name,
890
- tools: reflected.tools,
891
- instructions,
892
- ...options?.goal ? { goal: options.goal } : {}
893
- });
894
- await started;
939
+ this.sessions.set(sessionId, record);
940
+ try {
941
+ if (this.inline) {
942
+ const [serverTransport, clientTransport] = import_inMemory.InMemoryTransport.createLinkedPair();
943
+ await server.connect(serverTransport);
944
+ record.transport = serverTransport;
945
+ await this.inline.startSession({
946
+ sessionId,
947
+ sessionToken,
948
+ playerId,
949
+ name,
950
+ tools: reflected.tools,
951
+ instructions,
952
+ ...options.goal ? { goal: options.goal } : {},
953
+ ...this.logger ? { logger: this.logger } : {},
954
+ transport: clientTransport,
955
+ onTurnResult,
956
+ onEvent
957
+ });
958
+ } else {
959
+ if (!this.connection) throw new Error("Not connected");
960
+ const mux = this.connection.createTransport(sessionId);
961
+ await server.connect(mux);
962
+ record.transport = mux;
963
+ record.mux = mux;
964
+ const started = this.connection.waitForSessionEvent(sessionId, "start");
965
+ try {
966
+ this.connection.send({
967
+ type: "start_session",
968
+ sessionId,
969
+ sessionToken,
970
+ playerId,
971
+ name,
972
+ tools: reflected.tools,
973
+ instructions,
974
+ ...options.goal ? { goal: options.goal } : {}
975
+ });
976
+ await started;
977
+ } catch (error) {
978
+ const wrapped = error instanceof Error ? error : new Error(String(error));
979
+ this.connection.failSessionEvent(sessionId, "start", wrapped);
980
+ await started.catch(() => void 0);
981
+ throw wrapped;
982
+ }
983
+ }
984
+ } catch (error) {
985
+ await this.stopSession(sessionId);
986
+ throw error;
895
987
  }
896
988
  return {
897
989
  sessionId,
@@ -991,6 +1083,9 @@ var AgentServer = class {
991
1083
  }
992
1084
  case "error":
993
1085
  log(this.logger, "error", envelope.message);
1086
+ if (envelope.sessionId) {
1087
+ this.connection?.failSessionEvent(envelope.sessionId, "start", new Error(envelope.message));
1088
+ }
994
1089
  return;
995
1090
  case "session_started":
996
1091
  case "session_stopped":
@@ -1067,6 +1162,7 @@ var AgentServer = class {
1067
1162
  PlatformAuthError,
1068
1163
  ProtocolVersionError,
1069
1164
  TurnFailedError,
1070
- TurnTimeoutError
1165
+ TurnTimeoutError,
1166
+ bindDynamic
1071
1167
  });
1072
1168
  //# sourceMappingURL=index.cjs.map