@agent-os-lab/agent-game-sdk 0.1.1 → 0.1.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
@@ -2,13 +2,15 @@
2
2
 
3
3
  Embeddable agent game views and runtime clients.
4
4
 
5
+ For the full integration guide, see [USAGE.md](./USAGE.md).
6
+
5
7
  ## Office View
6
8
 
7
9
  Framework-neutral mount API:
8
10
 
9
11
  ```ts
10
- import { AgentGameRuntimeBrowserClient } from "agent-game-sdk";
11
- import { mountAgentGameOffice } from "agent-game-sdk/office";
12
+ import { AgentGameRuntimeBrowserClient } from "@agent-os-lab/agent-game-sdk";
13
+ import { mountAgentGameOffice } from "@agent-os-lab/agent-game-sdk/office";
12
14
 
13
15
  const client = new AgentGameRuntimeBrowserClient({
14
16
  baseUrl: "",
@@ -31,8 +33,8 @@ view.destroy();
31
33
  React wrapper:
32
34
 
33
35
  ```tsx
34
- import { AgentGameRuntimeBrowserClient } from "agent-game-sdk";
35
- import { AgentGameOfficeView } from "agent-game-sdk/office/react";
36
+ import { AgentGameRuntimeBrowserClient } from "@agent-os-lab/agent-game-sdk";
37
+ import { AgentGameOfficeView } from "@agent-os-lab/agent-game-sdk/office/react";
36
38
 
37
39
  const client = new AgentGameRuntimeBrowserClient({
38
40
  baseUrl: "",
@@ -51,13 +53,13 @@ export function Office() {
51
53
 
52
54
  `agent-game-sdk/office` is renderer and framework neutral. `agent-game-sdk/office/react` is the React-only entrypoint.
53
55
 
54
- Browser clients should call an application BFF endpoint. Keep the AgentOS service API key on the server:
56
+ Browser clients should call an application BFF endpoint. Keep the AgentOS service API key on the server; the SDK server client forwards it to Agent Game Runtime:
55
57
 
56
58
  ```ts
57
- import { AgentGameRuntimeServerClient } from "agent-game-sdk";
59
+ import { AgentGameRuntimeServerClient } from "@agent-os-lab/agent-game-sdk";
58
60
 
59
61
  const client = new AgentGameRuntimeServerClient({
60
- baseUrl: process.env.AGENTOS_BASE_URL!,
62
+ baseUrl: process.env.AGENT_GAME_RUNTIME_BASE_URL!,
61
63
  apiKey: process.env.AGENTOS_API_KEY!,
62
64
  });
63
65
 
@@ -71,7 +73,7 @@ export async function GET() {
71
73
  Run the SDK office view demo:
72
74
 
73
75
  ```bash
74
- AGENTOS_BASE_URL=http://localhost:3000 AGENTOS_API_KEY=... bun run demo
76
+ AGENT_GAME_RUNTIME_BASE_URL=http://localhost:3107 AGENTOS_API_KEY=... bun run demo
75
77
  ```
76
78
 
77
79
  Open `http://localhost:7357`. Set `PORT=7358` or another value to change the port.
package/USAGE.md ADDED
@@ -0,0 +1,303 @@
1
+ # Agent Game SDK Usage Guide
2
+
3
+ This document describes how to use `@agent-os-lab/agent-game-sdk` from browser-facing applications and trusted backend services.
4
+
5
+ ## Overview
6
+
7
+ `@agent-os-lab/agent-game-sdk` provides embeddable agent game views and a browser-safe client for Agent Game Runtime presence streams.
8
+
9
+ Use it when another application needs to render an AgentOS office view, subscribe to live agent presence, or bridge a trusted backend API key into short-lived runtime tokens for browser clients.
10
+
11
+ The SDK is intentionally split by responsibility:
12
+
13
+ - Runtime clients handle token creation and WebSocket subscription.
14
+ - Office view APIs mount a framework-neutral 3D office view into a DOM container.
15
+ - React APIs wrap the same office view for React applications.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @agent-os-lab/agent-game-sdk
21
+ ```
22
+
23
+ Runtime requirements:
24
+
25
+ - A modern browser runtime with `fetch`, `Headers`, `Response`, and `WebSocket` for frontend usage.
26
+ - A trusted backend runtime with `fetch` for runtime token proxy endpoints.
27
+ - React 19+ and React DOM 19+ only when using `@agent-os-lab/agent-game-sdk/office/react`.
28
+
29
+ ## Entry Points
30
+
31
+ ```ts
32
+ import {
33
+ AgentGameRuntimeBrowserClient,
34
+ AgentGameRuntimeServerClient,
35
+ } from "@agent-os-lab/agent-game-sdk";
36
+ import { mountAgentGameOffice } from "@agent-os-lab/agent-game-sdk/office";
37
+ import { AgentGameOfficeView } from "@agent-os-lab/agent-game-sdk/office/react";
38
+ import type { AgentPresence } from "@agent-os-lab/agent-game-sdk/office";
39
+ ```
40
+
41
+ Available entry points:
42
+
43
+ - `@agent-os-lab/agent-game-sdk`: runtime clients and office exports.
44
+ - `@agent-os-lab/agent-game-sdk/office`: framework-neutral office view APIs and office types.
45
+ - `@agent-os-lab/agent-game-sdk/office/react`: React office view component.
46
+
47
+ Do not import files from `src/` in application code. Use the published entry points above.
48
+
49
+ ## Authentication Model
50
+
51
+ Use `AgentGameRuntimeServerClient` only in trusted backend code. It sends the AgentOS service API key as a bearer token to Agent Game Runtime and returns a short-lived runtime token response.
52
+
53
+ ```ts
54
+ import { AgentGameRuntimeServerClient } from "@agent-os-lab/agent-game-sdk";
55
+
56
+ const serverClient = new AgentGameRuntimeServerClient({
57
+ baseUrl: process.env.AGENT_GAME_RUNTIME_BASE_URL!,
58
+ apiKey: process.env.AGENTOS_API_KEY!,
59
+ requestId: () => crypto.randomUUID(),
60
+ });
61
+
62
+ export async function GET() {
63
+ return Response.json(await serverClient.createRuntimeToken());
64
+ }
65
+ ```
66
+
67
+ Do not expose `AGENTOS_API_KEY` or any AgentOS service API key to browser code.
68
+
69
+ Use `AgentGameRuntimeBrowserClient` in the frontend against a same-origin BFF/proxy endpoint that returns `GameRuntimeTokenResponse`.
70
+
71
+ ```ts
72
+ import { AgentGameRuntimeBrowserClient } from "@agent-os-lab/agent-game-sdk";
73
+
74
+ const browserClient = new AgentGameRuntimeBrowserClient({
75
+ baseUrl: "",
76
+ tokenPath: "/api/agent-game-runtime-token",
77
+ });
78
+ ```
79
+
80
+ If your application backend expects an application-scoped browser token, provide `accessToken`. This token is for your BFF/proxy, not for Agent Game Runtime directly.
81
+
82
+ ```ts
83
+ const browserClient = new AgentGameRuntimeBrowserClient({
84
+ baseUrl: "",
85
+ tokenPath: "/api/agent-game-runtime-token",
86
+ accessToken: async () => getScopedApplicationToken(),
87
+ });
88
+ ```
89
+
90
+ The browser client strips caller-provided `authorization`, `x-hermes-tenant-id`, and `x-agentos-tenant-id` headers. Only the `accessToken` option may set a browser bearer token.
91
+
92
+ ## Runtime Subscription
93
+
94
+ Use `subscribe` when the application wants direct access to live runtime messages instead of using the office view.
95
+
96
+ ```ts
97
+ const subscription = await browserClient.subscribe({
98
+ onSnapshot: (message) => {
99
+ console.log("snapshot", message.agents);
100
+ },
101
+ onPatch: (message) => {
102
+ console.log("patch", message.agents);
103
+ },
104
+ onError: (error) => {
105
+ console.error("runtime stream error", error);
106
+ },
107
+ });
108
+
109
+ subscription.close();
110
+ ```
111
+
112
+ Runtime messages are either:
113
+
114
+ - `snapshot`: full current presence list for the tenant.
115
+ - `patch`: changed agent presence records.
116
+
117
+ Use `mergeAgentPresence` when maintaining your own local presence array from snapshots and patches.
118
+
119
+ ```ts
120
+ import { mergeAgentPresence } from "@agent-os-lab/agent-game-sdk";
121
+
122
+ let agents = [];
123
+
124
+ await browserClient.subscribe({
125
+ onSnapshot: (message) => {
126
+ agents = message.agents;
127
+ },
128
+ onPatch: (message) => {
129
+ agents = mergeAgentPresence(agents, message.agents);
130
+ },
131
+ });
132
+ ```
133
+
134
+ ## Office View
135
+
136
+ Use `mountAgentGameOffice` for framework-neutral embedding.
137
+
138
+ ```ts
139
+ import { AgentGameRuntimeBrowserClient } from "@agent-os-lab/agent-game-sdk";
140
+ import { mountAgentGameOffice } from "@agent-os-lab/agent-game-sdk/office";
141
+
142
+ const client = new AgentGameRuntimeBrowserClient({
143
+ baseUrl: "",
144
+ tokenPath: "/api/agent-game-runtime-token",
145
+ });
146
+
147
+ const view = await mountAgentGameOffice(container, {
148
+ renderer: "three",
149
+ source: {
150
+ type: "runtime",
151
+ client,
152
+ },
153
+ });
154
+
155
+ view.focusAgent("agent-1");
156
+ view.destroy();
157
+ ```
158
+
159
+ `renderer: "three"` is the built-in renderer and the default renderer. The container must have a stable size; the React wrapper supplies a default `minHeight`, but framework-neutral callers should size the container in CSS.
160
+
161
+ ## React Office View
162
+
163
+ Use `AgentGameOfficeView` when embedding the office view in a React application.
164
+
165
+ ```tsx
166
+ import { AgentGameRuntimeBrowserClient } from "@agent-os-lab/agent-game-sdk";
167
+ import { AgentGameOfficeView } from "@agent-os-lab/agent-game-sdk/office/react";
168
+
169
+ const client = new AgentGameRuntimeBrowserClient({
170
+ baseUrl: "",
171
+ });
172
+
173
+ export function Office() {
174
+ return (
175
+ <AgentGameOfficeView
176
+ renderer="three"
177
+ source={{ type: "runtime", client }}
178
+ focusedAgentId="agent-1"
179
+ style={{ height: 640 }}
180
+ />
181
+ );
182
+ }
183
+ ```
184
+
185
+ Keep `source` object identity stable across renders when possible. Recreating `source` every render can remount the view because the React wrapper treats `options.source` as an effect dependency.
186
+
187
+ ## Snapshot Source
188
+
189
+ Use `source.type: "snapshot"` for demos, tests, static previews, or applications that already have agent presence data.
190
+
191
+ ```ts
192
+ import { mountAgentGameOffice, type AgentPresence } from "@agent-os-lab/agent-game-sdk/office";
193
+
194
+ const agents: AgentPresence[] = [
195
+ {
196
+ tenantId: "tenant-demo",
197
+ agentId: "agent-1",
198
+ displayName: "Research Agent",
199
+ status: "working",
200
+ statusSource: "simulation",
201
+ activity: {
202
+ kind: "running",
203
+ summary: "Reading customer notes",
204
+ updatedAt: new Date().toISOString(),
205
+ },
206
+ updatedAt: new Date().toISOString(),
207
+ },
208
+ ];
209
+
210
+ const view = await mountAgentGameOffice(container, {
211
+ source: {
212
+ type: "snapshot",
213
+ agents,
214
+ },
215
+ });
216
+
217
+ view.updateAgents([
218
+ {
219
+ ...agents[0],
220
+ status: "meeting",
221
+ updatedAt: new Date().toISOString(),
222
+ },
223
+ ]);
224
+ ```
225
+
226
+ `updateAgents` only mutates SDK-managed snapshot sources. Runtime and custom sources own their own updates.
227
+
228
+ ## Custom Source
229
+
230
+ Use `source.type: "custom"` when another state manager already projects runtime state into office snapshots.
231
+
232
+ ```ts
233
+ import type {
234
+ AgentGameOfficeSnapshot,
235
+ AgentGameOfficeSource,
236
+ } from "@agent-os-lab/agent-game-sdk/office";
237
+
238
+ const source: AgentGameOfficeSource = {
239
+ subscribe(listener: (snapshot: AgentGameOfficeSnapshot) => void) {
240
+ const unsubscribe = store.subscribe(() => {
241
+ listener(store.getState().officeSnapshot);
242
+ });
243
+ listener(store.getState().officeSnapshot);
244
+ return unsubscribe;
245
+ },
246
+ };
247
+
248
+ await mountAgentGameOffice(container, {
249
+ source: {
250
+ type: "custom",
251
+ source,
252
+ },
253
+ });
254
+ ```
255
+
256
+ ## Agent Presence Shape
257
+
258
+ Office rendering is driven by `AgentPresence`.
259
+
260
+ Important fields:
261
+
262
+ - `tenantId`: tenant that owns the presence record.
263
+ - `agentId`: stable agent identifier.
264
+ - `displayName`: label shown in the office view.
265
+ - `status`: one of `working`, `thinking`, `meeting`, `resting`, `entertaining`, `idle`, or `offline`.
266
+ - `statusSource`: `runtime`, `simulation`, or `system`.
267
+ - `activity`: optional visible activity summary and preview.
268
+ - `updatedAt`: ISO timestamp for the latest presence update.
269
+ - `expiresAt`: optional ISO timestamp after which the presence should be considered stale by the producer.
270
+
271
+ ## Local Demo
272
+
273
+ Run the SDK office view demo from `agent-game-sdk`:
274
+
275
+ ```bash
276
+ AGENT_GAME_RUNTIME_BASE_URL=http://localhost:3107 AGENTOS_API_KEY=... bun run demo
277
+ ```
278
+
279
+ Open `http://localhost:7357`.
280
+
281
+ Set `PORT=7358` or another value to change the port.
282
+
283
+ ## Publish
284
+
285
+ Publishing is controlled from the SDK package directory. The script bumps the package version, builds the package, runs `npm pack --dry-run`, and then publishes to npm:
286
+
287
+ ```bash
288
+ bun run sdk:publish
289
+ ```
290
+
291
+ The default release is patch. Use `--minor` or `--major` when needed:
292
+
293
+ ```bash
294
+ bun run sdk:publish -- --minor
295
+ ```
296
+
297
+ Use `--dry-run` to validate the next version and package contents without publishing. The script restores the previous version after a dry run.
298
+
299
+ If npm requires two-factor authentication, pass the one-time password with `--otp`:
300
+
301
+ ```bash
302
+ bun run sdk:publish -- --otp 123456
303
+ ```
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@agent-os-lab/agent-game-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
7
- "README.md"
7
+ "README.md",
8
+ "USAGE.md"
8
9
  ],
9
10
  "exports": {
10
11
  ".": "./src/index.ts",
@@ -14,6 +15,7 @@
14
15
  "scripts": {
15
16
  "build": "tsc --noEmit -p tsconfig.json",
16
17
  "demo": "bun --env-file=.env.development --hot demo/server.ts",
18
+ "demo:stop": "bun scripts/stop-demo.ts",
17
19
  "sdk:publish": "bun scripts/publish-agent-game-sdk.ts",
18
20
  "test": "bun test",
19
21
  "typecheck": "tsc --noEmit -p tsconfig.json"
@@ -12,19 +12,12 @@ export type AgentActivityEffectMode =
12
12
 
13
13
  export type AgentActivityEffects = {
14
14
  group: THREE.Group;
15
- typingDots: THREE.Mesh[];
16
- toolBlock: THREE.Mesh;
17
- runningRing: THREE.Mesh;
18
- completionRing: THREE.Mesh;
15
+ typingDots: THREE.Object3D[];
16
+ toolBlock: THREE.Object3D;
17
+ runningRing: THREE.Object3D;
18
+ completionRing: THREE.Object3D;
19
19
  };
20
20
 
21
- const typingDotGeometry = new THREE.SphereGeometry(0.055, 12, 8);
22
- const typingDotMaterial = new THREE.MeshBasicMaterial({ color: 0xe0f2fe });
23
- const toolBlockGeometry = new THREE.BoxGeometry(0.34, 0.22, 0.08);
24
- const toolBlockMaterial = new THREE.MeshBasicMaterial({ color: 0x0f172a });
25
- const ringGeometryCache = new Map<string, THREE.RingGeometry>();
26
- const ringMaterialCache = new Map<number, THREE.MeshBasicMaterial>();
27
-
28
21
  export function resolveActivityEffectMode(
29
22
  agent: Pick<AgentGameOfficeAgent, "activity" | "activityLabel" | "sceneState">,
30
23
  ): AgentActivityEffectMode {
@@ -51,26 +44,20 @@ export function resolveActivityEffectMode(
51
44
  export function createAgentActivityEffects(): AgentActivityEffects {
52
45
  const group = new THREE.Group();
53
46
  const typingDots = [0, 1, 2].map((index) => {
54
- const dot = new THREE.Mesh(
55
- typingDotGeometry,
56
- typingDotMaterial,
57
- );
47
+ const dot = new THREE.Object3D();
58
48
  dot.position.set(-0.18 + index * 0.18, 1.95, 0.18);
59
49
  dot.visible = false;
60
50
  group.add(dot);
61
51
  return dot;
62
52
  });
63
53
 
64
- const toolBlock = new THREE.Mesh(
65
- toolBlockGeometry,
66
- toolBlockMaterial,
67
- );
54
+ const toolBlock = new THREE.Object3D();
68
55
  toolBlock.position.set(0.48, 1.18, 0.18);
69
56
  toolBlock.visible = false;
70
57
  group.add(toolBlock);
71
58
 
72
- const runningRing = createRing(0x38bdf8, 0.48, 0.68);
73
- const completionRing = createRing(0x22c55e, 0.58, 0.82);
59
+ const runningRing = createRing();
60
+ const completionRing = createRing();
74
61
  group.add(runningRing, completionRing);
75
62
 
76
63
  return { group, typingDots, toolBlock, runningRing, completionRing };
@@ -113,44 +100,14 @@ export function updateAgentActivityEffects(
113
100
  }
114
101
  }
115
102
 
116
- function createRing(color: number, innerRadius: number, outerRadius: number): THREE.Mesh {
117
- const ring = new THREE.Mesh(
118
- getRingGeometry(innerRadius, outerRadius),
119
- getRingMaterial(color),
120
- );
103
+ function createRing(): THREE.Object3D {
104
+ const ring = new THREE.Object3D();
121
105
  ring.rotation.x = -Math.PI / 2;
122
106
  ring.position.y = 0.04;
123
107
  ring.visible = false;
124
108
  return ring;
125
109
  }
126
110
 
127
- function getRingGeometry(innerRadius: number, outerRadius: number): THREE.RingGeometry {
128
- const key = `${innerRadius}:${outerRadius}`;
129
- const cached = ringGeometryCache.get(key);
130
- if (cached) {
131
- return cached;
132
- }
133
- const geometry = new THREE.RingGeometry(innerRadius, outerRadius, 32);
134
- ringGeometryCache.set(key, geometry);
135
- return geometry;
136
- }
137
-
138
- function getRingMaterial(color: number): THREE.MeshBasicMaterial {
139
- const cached = ringMaterialCache.get(color);
140
- if (cached) {
141
- return cached;
142
- }
143
- const material = new THREE.MeshBasicMaterial({
144
- color,
145
- transparent: true,
146
- opacity: 0.36,
147
- side: THREE.DoubleSide,
148
- depthWrite: false,
149
- });
150
- ringMaterialCache.set(color, material);
151
- return material;
152
- }
153
-
154
111
  function hideEffects(effects: AgentActivityEffects): void {
155
112
  effects.typingDots.forEach((dot) => {
156
113
  dot.visible = false;
@@ -41,7 +41,7 @@ export class AgentBodyInstancedLayer {
41
41
  }
42
42
  record.mesh.group.updateMatrixWorld(true);
43
43
  resolveAgentBodyPartRenderSpecs(record.renderIndex).forEach((part) => {
44
- const batch = this.ensureBatch(part, records.length);
44
+ const batch = this.ensureBatch(part, records.length * 2);
45
45
  const object = record.mesh[part.key];
46
46
  object.updateMatrixWorld(true);
47
47
  matrixHelper.copy(object.matrixWorld);
@@ -68,7 +68,7 @@ export class AgentBodyInstancedLayer {
68
68
  part: ReturnType<typeof resolveAgentBodyPartRenderSpecs>[number],
69
69
  requiredCapacity: number,
70
70
  ): AgentBodyBatch {
71
- const key = `${part.key}:${part.width}:${part.height}:${part.depth}:${part.color}`;
71
+ const key = `${part.width}:${part.height}:${part.depth}:${part.color}`;
72
72
  const existing = this.batches.get(key);
73
73
  if (existing && existing.capacity >= requiredCapacity) {
74
74
  return existing;
@@ -0,0 +1,134 @@
1
+ import * as THREE from "three";
2
+
3
+ import type { AgentMeshParts } from "./agent-mesh";
4
+
5
+ export type AgentEffectInstanceRecord = {
6
+ mesh: AgentMeshParts;
7
+ visible: boolean;
8
+ };
9
+
10
+ type EffectBatch = {
11
+ mesh: THREE.InstancedMesh;
12
+ capacity: number;
13
+ count: number;
14
+ };
15
+
16
+ const matrixHelper = new THREE.Matrix4();
17
+
18
+ export class AgentEffectInstancedLayer {
19
+ private readonly scene: THREE.Scene;
20
+ private readonly batches = new Map<string, EffectBatch>();
21
+
22
+ constructor(scene: THREE.Scene) {
23
+ this.scene = scene;
24
+ }
25
+
26
+ update(records: AgentEffectInstanceRecord[]): void {
27
+ this.batches.forEach((batch) => {
28
+ batch.count = 0;
29
+ batch.mesh.count = 0;
30
+ });
31
+
32
+ records.forEach((record) => {
33
+ if (!record.visible) {
34
+ return;
35
+ }
36
+ record.mesh.group.updateMatrixWorld(true);
37
+ this.addObject("glow", record.mesh.activityGlow, records.length);
38
+ record.mesh.activityEffects.typingDots.forEach((dot) => {
39
+ this.addObject("typingDot", dot, records.length * 3);
40
+ });
41
+ this.addObject("toolBlock", record.mesh.activityEffects.toolBlock, records.length);
42
+ this.addObject("runningRing", record.mesh.activityEffects.runningRing, records.length);
43
+ this.addObject("completionRing", record.mesh.activityEffects.completionRing, records.length);
44
+ });
45
+
46
+ this.batches.forEach((batch) => {
47
+ batch.mesh.count = batch.count;
48
+ batch.mesh.visible = batch.count > 0;
49
+ batch.mesh.instanceMatrix.needsUpdate = true;
50
+ });
51
+ }
52
+
53
+ dispose(): void {
54
+ this.batches.forEach((batch) => {
55
+ this.scene.remove(batch.mesh);
56
+ });
57
+ this.batches.clear();
58
+ }
59
+
60
+ private addObject(key: EffectKey, object: THREE.Object3D, capacity: number): void {
61
+ if (!object.visible) {
62
+ return;
63
+ }
64
+ const batch = this.ensureBatch(key, capacity);
65
+ object.updateMatrixWorld(true);
66
+ matrixHelper.copy(object.matrixWorld);
67
+ batch.mesh.setMatrixAt(batch.count, matrixHelper);
68
+ batch.count += 1;
69
+ }
70
+
71
+ private ensureBatch(key: EffectKey, requiredCapacity: number): EffectBatch {
72
+ const existing = this.batches.get(key);
73
+ if (existing && existing.capacity >= requiredCapacity) {
74
+ return existing;
75
+ }
76
+ if (existing) {
77
+ this.scene.remove(existing.mesh);
78
+ }
79
+
80
+ const spec = effectSpecs[key];
81
+ const mesh = new THREE.InstancedMesh(
82
+ spec.geometry,
83
+ spec.material,
84
+ Math.max(requiredCapacity, 1),
85
+ );
86
+ mesh.castShadow = false;
87
+ mesh.receiveShadow = false;
88
+ mesh.name = `agentEffect:${key}`;
89
+ this.scene.add(mesh);
90
+
91
+ const batch = { mesh, capacity: Math.max(requiredCapacity, 1), count: 0 };
92
+ this.batches.set(key, batch);
93
+ return batch;
94
+ }
95
+ }
96
+
97
+ export function createAgentEffectInstancedLayer(scene: THREE.Scene): AgentEffectInstancedLayer {
98
+ return new AgentEffectInstancedLayer(scene);
99
+ }
100
+
101
+ type EffectKey = "glow" | "typingDot" | "toolBlock" | "runningRing" | "completionRing";
102
+
103
+ const effectSpecs: Record<EffectKey, { geometry: THREE.BufferGeometry; material: THREE.Material }> = {
104
+ glow: {
105
+ geometry: new THREE.RingGeometry(0.56, 0.92, 32),
106
+ material: createTransparentMaterial(0x38bdf8, 0.35),
107
+ },
108
+ typingDot: {
109
+ geometry: new THREE.SphereGeometry(0.055, 12, 8),
110
+ material: new THREE.MeshBasicMaterial({ color: 0xe0f2fe }),
111
+ },
112
+ toolBlock: {
113
+ geometry: new THREE.BoxGeometry(0.34, 0.22, 0.08),
114
+ material: new THREE.MeshBasicMaterial({ color: 0x0f172a }),
115
+ },
116
+ runningRing: {
117
+ geometry: new THREE.RingGeometry(0.48, 0.68, 32),
118
+ material: createTransparentMaterial(0x38bdf8, 0.36),
119
+ },
120
+ completionRing: {
121
+ geometry: new THREE.RingGeometry(0.58, 0.82, 32),
122
+ material: createTransparentMaterial(0x22c55e, 0.36),
123
+ },
124
+ };
125
+
126
+ function createTransparentMaterial(color: number, opacity: number): THREE.MeshBasicMaterial {
127
+ return new THREE.MeshBasicMaterial({
128
+ color,
129
+ transparent: true,
130
+ opacity,
131
+ side: THREE.DoubleSide,
132
+ depthWrite: false,
133
+ });
134
+ }
@@ -21,23 +21,14 @@ export type AgentMeshParts = {
21
21
  rightLeg: THREE.Object3D;
22
22
  leftFoot: THREE.Object3D;
23
23
  rightFoot: THREE.Object3D;
24
- activityGlow: THREE.Mesh;
24
+ activityGlow: THREE.Object3D;
25
25
  activityEffects: AgentActivityEffects;
26
26
  };
27
27
 
28
- const agentColors = [0x2563eb, 0xdc2626, 0x059669, 0xd97706, 0x7c3aed, 0x0891b2];
29
- const skinColors = [0xffdbac, 0xf5cba7, 0xd4a574, 0xc68642];
30
- const hairColors = [0x3f2a1d, 0x111827, 0x7c2d12, 0x4b5563];
28
+ const agentColors = [0x2563eb, 0xdc2626, 0x059669];
29
+ const skinColors = [0xffdbac, 0xd4a574];
30
+ const hairColors = [0x3f2a1d, 0x111827];
31
31
  export const AGENT_MESH_SCALE = 1.15;
32
- const activityGlowGeometry = new THREE.RingGeometry(0.56, 0.92, 32);
33
- const activityGlowMaterial = new THREE.MeshBasicMaterial({
34
- color: 0x38bdf8,
35
- transparent: true,
36
- opacity: 0.35,
37
- side: THREE.DoubleSide,
38
- depthWrite: false,
39
- });
40
-
41
32
  export type AgentBodyPartKey =
42
33
  | "body"
43
34
  | "leftLeg"
@@ -133,11 +124,8 @@ function addPart(
133
124
  return part;
134
125
  }
135
126
 
136
- function createActivityGlow(): THREE.Mesh {
137
- const glow = new THREE.Mesh(
138
- activityGlowGeometry,
139
- activityGlowMaterial,
140
- );
127
+ function createActivityGlow(): THREE.Object3D {
128
+ const glow = new THREE.Object3D();
141
129
  glow.rotation.x = -Math.PI / 2;
142
130
  glow.position.y = 0.03;
143
131
  glow.visible = false;
@@ -9,6 +9,7 @@ import type {
9
9
  } from "../../core/types";
10
10
  import { applyAgentPose, updateAgentMotion } from "./agent-animation";
11
11
  import { createAgentBodyInstancedLayer } from "./agent-body-instancing";
12
+ import { createAgentEffectInstancedLayer } from "./agent-effect-instancing";
12
13
  import { resolveAgentFacingTarget, resolveAgentPosition } from "./agent-layout";
13
14
  import {
14
15
  type AgentLabelRecord,
@@ -113,6 +114,7 @@ export function mountThreeAgentGameOffice(
113
114
  addLights(scene);
114
115
  buildOfficeScene(scene);
115
116
  const agentBodyLayer = createAgentBodyInstancedLayer(scene);
117
+ const agentEffectLayer = createAgentEffectInstancedLayer(scene);
116
118
 
117
119
  const agents = new Map<string, AgentMeshRecord>();
118
120
  let frameId = 0;
@@ -193,6 +195,10 @@ export function mountThreeAgentGameOffice(
193
195
  renderIndex: record.renderIndex,
194
196
  visible: record.agent.sceneState !== "offline",
195
197
  })));
198
+ agentEffectLayer.update(Array.from(agents.values()).map((record) => ({
199
+ mesh: record.mesh,
200
+ visible: record.agent.sceneState !== "offline",
201
+ })));
196
202
  renderer.render(scene, camera);
197
203
  labelsDirty = false;
198
204
  frameId = requestAnimationFrame(animate);
@@ -245,6 +251,7 @@ export function mountThreeAgentGameOffice(
245
251
  });
246
252
  agents.clear();
247
253
  agentBodyLayer.dispose();
254
+ agentEffectLayer.dispose();
248
255
  disposeObject(scene);
249
256
  renderer.dispose();
250
257
  root.remove();
@@ -158,7 +158,7 @@ export class AgentGameRuntimeBrowserClient {
158
158
  signal: options.signal,
159
159
  });
160
160
  if (!response.ok) {
161
- throw new Error(`Game runtime token request failed with status ${response.status}.`);
161
+ throw new Error(await formatGameRuntimeTokenRequestError(response));
162
162
  }
163
163
  const body = await response.json() as GameRuntimeTokenResponse;
164
164
  if (!isGameRuntimeTokenResponse(body)) {
@@ -217,7 +217,7 @@ export class AgentGameRuntimeServerClient {
217
217
  signal: options.signal,
218
218
  });
219
219
  if (!response.ok) {
220
- throw new Error(`Game runtime token request failed with status ${response.status}.`);
220
+ throw new Error(await formatGameRuntimeTokenRequestError(response));
221
221
  }
222
222
  const body = await response.json() as GameRuntimeTokenResponse;
223
223
  if (!isGameRuntimeTokenResponse(body)) {
@@ -377,6 +377,31 @@ function boundFetch(): typeof fetch {
377
377
  return globalThis.fetch.bind(globalThis);
378
378
  }
379
379
 
380
+ async function formatGameRuntimeTokenRequestError(response: Response): Promise<string> {
381
+ const detail = await readErrorDetail(response);
382
+ return `Game runtime token request failed with status ${response.status}${detail ? `: ${detail}` : ""}.`;
383
+ }
384
+
385
+ async function readErrorDetail(response: Response): Promise<string> {
386
+ try {
387
+ const body = await response.clone().json() as unknown;
388
+ if (!body || typeof body !== "object") {
389
+ return "";
390
+ }
391
+ const error = (body as { error?: unknown }).error;
392
+ if (typeof error === "string") {
393
+ return error;
394
+ }
395
+ if (error && typeof error === "object") {
396
+ const message = (error as { message?: unknown }).message;
397
+ return typeof message === "string" ? message : "";
398
+ }
399
+ } catch {
400
+ return "";
401
+ }
402
+ return "";
403
+ }
404
+
380
405
  function resolveHeaders(
381
406
  value: AgentGameRuntimeBrowserClientOptions["headers"] | AgentGameRuntimeServerClientOptions["headers"],
382
407
  ): HeadersInit | undefined {