agent-player 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,412 @@
1
+ """One in-process MCP server and one outbound connection to the platform.
2
+
3
+ Every start() is its own session. The game calls turn() to decide whose turn
4
+ it is. The platform chooses the action.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from typing import Any, Callable
13
+
14
+ from whitesands.agent_player.errors import ConnectionLostError, TurnFailedError
15
+ from whitesands.agent_player.events import AgentEvent
16
+ from whitesands.agent_player.logger import log
17
+ from whitesands.agent_player.mcp import InMemoryTransport, SessionActionLog, SessionMcpServer
18
+ from whitesands.agent_player.memory import SessionMemory, default_memory_path
19
+ from whitesands.agent_player.platform_connection import PlatformConnection
20
+ from whitesands.agent_player.protocol import (
21
+ DEFAULT_AGENT_INSTRUCTIONS,
22
+ DEFAULT_PLATFORM_URL,
23
+ TokenUsage,
24
+ TurnReport,
25
+ turn_report_from_wire,
26
+ )
27
+ from whitesands.agent_player.reflect import reflect_player
28
+ from whitesands.agent_player.turn_gate import TurnGate
29
+
30
+ DEFAULT_TURN_TIMEOUT_MS = 60_000
31
+
32
+ EventListener = Callable[[AgentEvent], None]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class TurnResult:
37
+ """What the agent did on one granted turn."""
38
+
39
+ action: str
40
+ args: dict[str, Any]
41
+ result: Any
42
+ attempts: int
43
+ usage: TokenUsage
44
+
45
+
46
+ @dataclass
47
+ class StartSession:
48
+ """What an inline platform receives for one player."""
49
+
50
+ session_id: str
51
+ session_token: str
52
+ name: str
53
+ tools: list[dict[str, Any]]
54
+ instructions: str
55
+ transport: Any
56
+ on_turn_result: Callable[[TurnReport], None]
57
+ goal: str | None = None
58
+ logger: Any = None
59
+ on_event: Callable[[AgentEvent], None] | None = None
60
+ on_usage: Callable[[int], Any] | None = None
61
+
62
+
63
+ class AgentHandle:
64
+ """One started player. turn() grants that character one chance to act."""
65
+
66
+ def __init__(
67
+ self,
68
+ session_id: str,
69
+ turn: Callable[..., Any],
70
+ stop: Callable[[], Any],
71
+ on: Callable[[EventListener], Callable[[], None]],
72
+ ) -> None:
73
+ self.session_id = session_id
74
+ self._turn = turn
75
+ self._stop = stop
76
+ self._on = on
77
+
78
+ async def turn(self, *, timeout_ms: int | None = None, signal: Any = None) -> TurnResult:
79
+ """Grant this session one turn.
80
+
81
+ Resolves with what the agent did, or raises after a second failed
82
+ action, a timeout, or a lost connection.
83
+ """
84
+ return await self._turn(timeout_ms=timeout_ms, signal=signal)
85
+
86
+ async def stop(self) -> None:
87
+ """Stop the remote agent for this session. The AgentServer stays up."""
88
+ await self._stop()
89
+
90
+ def on(self, listener: EventListener) -> Callable[[], None]:
91
+ """Subscribe to this session's action, retry, and failure events."""
92
+ return self._on(listener)
93
+
94
+
95
+ class _Session:
96
+ def __init__(
97
+ self,
98
+ session_id: str,
99
+ token: str,
100
+ name: str,
101
+ gate: TurnGate,
102
+ server: SessionMcpServer,
103
+ ) -> None:
104
+ self.id = session_id
105
+ self.token = token
106
+ self.name = name
107
+ self.gate = gate
108
+ self.server = server
109
+ self.listeners: set[EventListener] = set()
110
+ self.mux: Any = None
111
+
112
+
113
+ class AgentServer:
114
+ def __init__(
115
+ self,
116
+ *,
117
+ platform_key: str | None = None,
118
+ platform_url: str | None = None,
119
+ inline: Any = None,
120
+ instructions: str | None = None,
121
+ turn_timeout_ms: int | None = None,
122
+ on_event: EventListener | None = None,
123
+ logger: Any = None,
124
+ memory_path: str | None | bool = None,
125
+ ) -> None:
126
+ resolved_key = platform_key if platform_key is not None else os.environ.get("WSI_PLATFORM_KEY")
127
+ if inline is None and not resolved_key:
128
+ raise RuntimeError(
129
+ "Set WSI_PLATFORM_KEY or pass platform_key, unless an inline platform is provided"
130
+ )
131
+ self._platform_key = resolved_key or "inline"
132
+ self._platform_url = platform_url or os.environ.get("WSI_PLATFORM_URL") or DEFAULT_PLATFORM_URL
133
+ self._inline = inline
134
+ self._server_instructions = instructions
135
+ self._turn_timeout_ms = turn_timeout_ms
136
+ self._logger = logger
137
+ self._memory_path = memory_path
138
+ self._connection: PlatformConnection | None = None
139
+ self._sessions: dict[str, _Session] = {}
140
+ self._listeners: set[EventListener] = set()
141
+ self._memory: SessionMemory | None = None
142
+ self._started = False
143
+ self._closing = False
144
+ if on_event is not None:
145
+ self._listeners.add(on_event)
146
+
147
+ async def __aenter__(self) -> AgentServer:
148
+ return self
149
+
150
+ async def __aexit__(self, *_exc: object) -> None:
151
+ await self.close()
152
+
153
+ async def connect(self) -> None:
154
+ """Open the outbound platform connection. No-op for an inline platform."""
155
+ if self._inline is not None or self._started:
156
+ self._started = True
157
+ return
158
+ self._connection = PlatformConnection(
159
+ self._platform_url,
160
+ self._platform_key,
161
+ on_envelope=self._handle_envelope,
162
+ on_disconnect=lambda: self._emit(AgentEvent(type="disconnected")),
163
+ on_reconnect=lambda: self._emit(AgentEvent(type="reconnected")),
164
+ on_error=self._on_connection_error,
165
+ sessions=lambda: [
166
+ {"session_id": session.id, "session_token": session.token}
167
+ for session in self._sessions.values()
168
+ ],
169
+ logger=self._logger,
170
+ )
171
+ await self._connection.connect()
172
+ self._started = True
173
+
174
+ async def start(
175
+ self,
176
+ instance: object,
177
+ *,
178
+ name: str | None = None,
179
+ goal: str | None = None,
180
+ instructions: str | None = None,
181
+ ) -> AgentHandle:
182
+ """Reflect the player into tools, register a session, and return a handle."""
183
+ await self.connect()
184
+ reflected = reflect_player(instance)
185
+ session_id = str(uuid.uuid4())
186
+ session_token = str(uuid.uuid4())
187
+ gate = TurnGate()
188
+ player_name = name or type(instance).__name__ or "player"
189
+ memory = self._action_memory()
190
+ actions = SessionActionLog(memory, session_id, player_name) if memory is not None else None
191
+ server = SessionMcpServer(
192
+ reflected,
193
+ gate,
194
+ name=f"agent-player:{player_name}",
195
+ actions=actions,
196
+ )
197
+ prompt = _join_instructions(self._server_instructions, instructions)
198
+ session = _Session(session_id, session_token, player_name, gate, server)
199
+
200
+ if self._inline is not None:
201
+ server_transport, client_transport = InMemoryTransport.create_linked_pair()
202
+ await server.connect(server_transport)
203
+ self._sessions[session_id] = session
204
+ await self._inline.start_session(
205
+ StartSession(
206
+ session_id=session_id,
207
+ session_token=session_token,
208
+ name=player_name,
209
+ tools=reflected.tools,
210
+ instructions=prompt,
211
+ transport=client_transport,
212
+ on_turn_result=gate.complete_turn,
213
+ goal=goal,
214
+ logger=self._logger,
215
+ on_event=self._emit,
216
+ )
217
+ )
218
+ else:
219
+ connection = self._connection
220
+ if connection is None:
221
+ raise RuntimeError("Not connected")
222
+ mux = connection.create_transport(session_id)
223
+ await server.connect(mux)
224
+ session.mux = mux
225
+ self._sessions[session_id] = session
226
+ started = connection.begin_wait(session_id, "start")
227
+ body: dict[str, Any] = {
228
+ "type": "start_session",
229
+ "sessionId": session_id,
230
+ "sessionToken": session_token,
231
+ "name": player_name,
232
+ "tools": reflected.tools,
233
+ "instructions": prompt,
234
+ }
235
+ if goal:
236
+ body["goal"] = goal
237
+ connection.send(body)
238
+ await started
239
+
240
+ return AgentHandle(
241
+ session_id,
242
+ lambda **kwargs: self._turn(session_id, **kwargs),
243
+ lambda: self._stop_session(session_id),
244
+ lambda listener: self._on_session(session_id, listener),
245
+ )
246
+
247
+ def on(self, listener: EventListener) -> Callable[[], None]:
248
+ """Subscribe to every session, plus connection events."""
249
+ self._listeners.add(listener)
250
+
251
+ def unsubscribe() -> None:
252
+ self._listeners.discard(listener)
253
+
254
+ return unsubscribe
255
+
256
+ async def close(self) -> None:
257
+ """Stop every session and close the platform connection."""
258
+ if self._closing:
259
+ return
260
+ self._closing = True
261
+ try:
262
+ for session_id in list(self._sessions):
263
+ await self._stop_session(session_id)
264
+ if self._inline is not None and hasattr(self._inline, "close"):
265
+ await self._inline.close()
266
+ if self._connection is not None:
267
+ await self._connection.close()
268
+ self._connection = None
269
+ if self._memory is not None:
270
+ self._memory.close()
271
+ self._memory = None
272
+ self._started = False
273
+ finally:
274
+ self._closing = False
275
+
276
+ async def _turn(self, session_id: str, *, timeout_ms: int | None = None, signal: Any = None) -> TurnResult:
277
+ session = self._sessions.get(session_id)
278
+ if session is None:
279
+ raise RuntimeError(f"Unknown session {session_id}")
280
+ if timeout_ms is None:
281
+ timeout_ms = self._turn_timeout_ms if self._turn_timeout_ms is not None else DEFAULT_TURN_TIMEOUT_MS
282
+ outcome = await session.gate.run_turn(timeout_ms=timeout_ms, signal=signal)
283
+ if not outcome.ok:
284
+ if outcome.connection_lost:
285
+ raise ConnectionLostError(outcome.error or "Connection to the platform was lost")
286
+ raise TurnFailedError(outcome.error or "turn failed", outcome.attempt_details)
287
+ return TurnResult(
288
+ action=outcome.action or "unknown",
289
+ args=dict(outcome.args or {}),
290
+ result=outcome.result,
291
+ attempts=outcome.attempts or 1,
292
+ usage=outcome.usage or TokenUsage(),
293
+ )
294
+
295
+ def _on_session(self, session_id: str, listener: EventListener) -> Callable[[], None]:
296
+ session = self._sessions.get(session_id)
297
+ if session is None:
298
+ return lambda: None
299
+ session.listeners.add(listener)
300
+
301
+ def unsubscribe() -> None:
302
+ session.listeners.discard(listener)
303
+
304
+ return unsubscribe
305
+
306
+ def _emit(self, event: AgentEvent) -> None:
307
+ for listener in list(self._listeners):
308
+ listener(event)
309
+ if event.type in ("disconnected", "reconnected") or not event.session_id:
310
+ return
311
+ session = self._sessions.get(event.session_id)
312
+ if session is None:
313
+ return
314
+ for listener in list(session.listeners):
315
+ listener(event)
316
+
317
+ def _on_connection_error(self, error: BaseException) -> None:
318
+ log(self._logger, "error", str(error))
319
+ if isinstance(error, ConnectionLostError):
320
+ for session in self._sessions.values():
321
+ session.gate.fail_turn(error)
322
+
323
+ def _handle_envelope(self, envelope: dict[str, Any]) -> None:
324
+ kind = envelope.get("type")
325
+ session_id = envelope.get("sessionId")
326
+ session = self._sessions.get(session_id) if isinstance(session_id, str) else None
327
+ if kind == "mcp":
328
+ if session is not None and session.mux is not None:
329
+ session.mux.receive(envelope.get("message"))
330
+ return
331
+ if kind == "turn_result":
332
+ if session is not None:
333
+ session.gate.complete_turn(turn_report_from_wire(envelope))
334
+ return
335
+ if kind == "session_event":
336
+ self._emit_wire_event(envelope)
337
+ return
338
+ if kind == "error":
339
+ log(self._logger, "error", str(envelope.get("message") or ""))
340
+
341
+ def _emit_wire_event(self, envelope: dict[str, Any]) -> None:
342
+ session_id = str(envelope.get("sessionId") or "")
343
+ name = envelope.get("event")
344
+ action = envelope.get("action")
345
+ args = envelope.get("args") if isinstance(envelope.get("args"), dict) else None
346
+ if name == "action":
347
+ self._emit(
348
+ AgentEvent(
349
+ type="action",
350
+ session_id=session_id,
351
+ action=str(action or "unknown"),
352
+ args=dict(args or {}),
353
+ )
354
+ )
355
+ return
356
+ if name == "retry":
357
+ self._emit(
358
+ AgentEvent(
359
+ type="retry",
360
+ session_id=session_id,
361
+ action=str(action or "unknown"),
362
+ args=dict(args or {}),
363
+ error=str(envelope.get("error") or "action failed"),
364
+ )
365
+ )
366
+ return
367
+ self._emit(
368
+ AgentEvent(
369
+ type="turnFailed",
370
+ session_id=session_id,
371
+ error=str(envelope.get("error") or "turn failed"),
372
+ action=str(action) if action else None,
373
+ args=dict(args) if args else None,
374
+ )
375
+ )
376
+
377
+ async def _stop_session(self, session_id: str) -> None:
378
+ session = self._sessions.pop(session_id, None)
379
+ if session is None:
380
+ return
381
+ session.gate.reset()
382
+ if self._inline is not None:
383
+ await self._inline.stop_session(session_id)
384
+ elif self._connection is not None:
385
+ connection = self._connection
386
+ waiter = connection.begin_wait(session_id, "stop")
387
+ try:
388
+ connection.send({"type": "stop_session", "sessionId": session_id})
389
+ except Exception:
390
+ connection.cancel_wait(session_id, "stop")
391
+ try:
392
+ await waiter
393
+ except Exception:
394
+ pass
395
+ try:
396
+ await session.server.close()
397
+ except Exception:
398
+ pass
399
+
400
+ def _action_memory(self) -> SessionMemory | None:
401
+ if self._memory_path is False:
402
+ return None
403
+ if self._memory is None:
404
+ path = self._memory_path if isinstance(self._memory_path, str) else default_memory_path()
405
+ self._memory = SessionMemory(path)
406
+ return self._memory
407
+
408
+
409
+ def _join_instructions(server_instructions: str | None, start_instructions: str | None) -> str:
410
+ base = DEFAULT_AGENT_INSTRUCTIONS if server_instructions is None else server_instructions
411
+ parts = [part for part in (base, start_instructions) if part]
412
+ return "\n\n".join(parts)
@@ -0,0 +1,35 @@
1
+ """Typed failures raised by AgentServer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from whitesands.agent_player.protocol import Attempt
9
+
10
+
11
+ class PlatformAuthError(Exception):
12
+ """The platform rejected the key."""
13
+
14
+
15
+ class ProtocolVersionError(Exception):
16
+ """This SDK and the platform disagree on the protocol."""
17
+
18
+
19
+ class TurnFailedError(Exception):
20
+ """The action and the retry both failed."""
21
+
22
+ def __init__(self, message: str, attempts: list[Attempt] | None = None) -> None:
23
+ super().__init__(message)
24
+ self.attempts = list(attempts or [])
25
+
26
+
27
+ class TurnTimeoutError(Exception):
28
+ """The platform did not finish within the turn timeout."""
29
+
30
+
31
+ class ConnectionLostError(Exception):
32
+ """The socket dropped and the session could not be resumed."""
33
+
34
+ def __init__(self, message: str = "Connection to the platform was lost") -> None:
35
+ super().__init__(message)
@@ -0,0 +1,22 @@
1
+ """Events emitted while a session acts, or while the platform connection changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AgentEvent:
11
+ """One action, retry, failure, or connection change.
12
+
13
+ ``disconnected`` and ``reconnected`` have no session id. The other events
14
+ do. ``type`` is ``action``, ``retry``, ``turnFailed``, ``disconnected``,
15
+ or ``reconnected``.
16
+ """
17
+
18
+ type: str
19
+ session_id: str | None = None
20
+ action: str | None = None
21
+ args: dict[str, Any] | None = None
22
+ error: str | None = None
@@ -0,0 +1,22 @@
1
+ """Optional logger. Omit it and the SDK writes nothing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def log(logger: Any, level: str, message: str, extra: Any = None) -> None:
9
+ if logger is None:
10
+ return
11
+ fn = getattr(logger, level, None)
12
+ if fn is None and level == "warn":
13
+ fn = getattr(logger, "warning", None)
14
+ if not callable(fn):
15
+ return
16
+ try:
17
+ if extra is None:
18
+ fn(message)
19
+ else:
20
+ fn(message, extra)
21
+ except TypeError:
22
+ fn(message)