@whitesands/agent-player 1.1.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,12 @@
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
+
3
10
  ## 1.1.0
4
11
 
5
12
  ### Minor Changes
package/README.md CHANGED
@@ -40,7 +40,7 @@ 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_...
@@ -53,6 +53,7 @@ import { AgentServer } from "@whitesands/agent-player";
53
53
 
54
54
  const agents = new AgentServer();
55
55
  const ada = await agents.start(new Villager(world, "Ada"), {
56
+ playerId: "3f6c2d1e-8b7a-4c5d-9e0f-1a2b3c4d5e6f",
56
57
  name: "Ada",
57
58
  goal: "Stand on the tree and chop it",
58
59
  });
@@ -127,6 +128,28 @@ class Villager {
127
128
  }
128
129
  ```
129
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
+
130
153
  ### Turns
131
154
 
132
155
  `turn()` does not choose the action. It unblocks that session's `wait`. The platform reads `see()`, calls one action, and reports back.
@@ -176,11 +199,19 @@ try {
176
199
 
177
200
  ### Several characters
178
201
 
179
- 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.
180
203
 
181
204
  ```ts
182
- const ada = await agents.start(new Villager(world, "Ada"), { name: "Ada", goal: "Chop the tree" });
183
- 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
+ });
184
215
 
185
216
  await ada.turn();
186
217
  await bea.turn();
@@ -234,6 +265,7 @@ const agents = new AgentServer({
234
265
 
235
266
  ```ts
236
267
  await agents.start(ferry, {
268
+ playerId: "3f6c2d1e-8b7a-4c5d-9e0f-1a2b3c4d5e6f",
237
269
  name: "Ferryman",
238
270
  goal: "Get the wolf, the goat, and the cabbage onto the far bank.",
239
271
  });
@@ -241,7 +273,7 @@ await agents.start(ferry, {
241
273
 
242
274
  ### Testing without a platform
243
275
 
244
- `@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`.
245
277
 
246
278
  ```ts
247
279
  import { AgentServer } from "@whitesands/agent-player";
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.1.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,
@@ -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