inter-agent-core 0.2.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.
Files changed (80) hide show
  1. inter_agent/__init__.py +0 -0
  2. inter_agent/core/__init__.py +1 -0
  3. inter_agent/core/adapter_control.py +350 -0
  4. inter_agent/core/auth.py +279 -0
  5. inter_agent/core/channels.py +131 -0
  6. inter_agent/core/client.py +345 -0
  7. inter_agent/core/config.py +325 -0
  8. inter_agent/core/errors.py +31 -0
  9. inter_agent/core/kick.py +111 -0
  10. inter_agent/core/list.py +127 -0
  11. inter_agent/core/publish.py +146 -0
  12. inter_agent/core/router.py +8 -0
  13. inter_agent/core/send.py +265 -0
  14. inter_agent/core/server.py +765 -0
  15. inter_agent/core/shared.py +182 -0
  16. inter_agent/core/shutdown.py +92 -0
  17. inter_agent/core/status.py +291 -0
  18. inter_agent/core/tls.py +141 -0
  19. inter_agent/core/transport.py +37 -0
  20. inter_agent/py.typed +0 -0
  21. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/asyncapi.yaml +225 -0
  22. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/error-codes.md +28 -0
  23. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_challenge.json +6 -0
  24. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_response.json +4 -0
  25. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/broadcast.json +4 -0
  26. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/bye.json +3 -0
  27. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels.json +3 -0
  28. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels_ok.json +9 -0
  29. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/custom.unknown-pass-through.json +9 -0
  30. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/error.auth-failed.json +5 -0
  31. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent-with-label.json +14 -0
  32. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent.json +13 -0
  33. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick.json +4 -0
  34. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick_ok.json +5 -0
  35. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list.json +3 -0
  36. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list_ok.json +10 -0
  37. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.custom.json +13 -0
  38. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.text.json +9 -0
  39. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/ping.json +3 -0
  40. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/pong.json +3 -0
  41. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/publish.json +5 -0
  42. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/send.direct.json +5 -0
  43. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown.json +3 -0
  44. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown_ok.json +3 -0
  45. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe.json +4 -0
  46. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe_ok.json +4 -0
  47. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe.json +4 -0
  48. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe_ok.json +4 -0
  49. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/welcome.json +10 -0
  50. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_challenge.json +13 -0
  51. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_response.json +11 -0
  52. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/broadcast.json +18 -0
  53. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/bye.json +10 -0
  54. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels.json +10 -0
  55. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels_ok.json +26 -0
  56. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/custom.json +22 -0
  57. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/error.json +37 -0
  58. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/hello.json +59 -0
  59. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick.json +13 -0
  60. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick_ok.json +12 -0
  61. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list.json +10 -0
  62. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list_ok.json +24 -0
  63. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/msg.json +29 -0
  64. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/ping.json +10 -0
  65. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/pong.json +10 -0
  66. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/publish.json +23 -0
  67. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/send.json +22 -0
  68. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown.json +10 -0
  69. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown_ok.json +10 -0
  70. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe.json +15 -0
  71. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe_ok.json +11 -0
  72. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe.json +15 -0
  73. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe_ok.json +11 -0
  74. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/welcome.json +36 -0
  75. inter_agent_core-0.2.0.dist-info/METADATA +85 -0
  76. inter_agent_core-0.2.0.dist-info/RECORD +80 -0
  77. inter_agent_core-0.2.0.dist-info/WHEEL +5 -0
  78. inter_agent_core-0.2.0.dist-info/entry_points.txt +10 -0
  79. inter_agent_core-0.2.0.dist-info/licenses/LICENSE.md +21 -0
  80. inter_agent_core-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,765 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import http
6
+ import json
7
+ import signal
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import websockets
13
+ from websockets.asyncio.server import ServerConnection
14
+ from websockets.http11 import Request, Response
15
+
16
+ from inter_agent.core.auth import build_auth_challenge, client_nonce_from_hello, verify_client_proof
17
+ from inter_agent.core.errors import ErrorCode
18
+ from inter_agent.core.router import RouterMiddleware
19
+ from inter_agent.core.shared import (
20
+ Limits,
21
+ next_msg_id,
22
+ resolve_endpoint,
23
+ resolve_shared_secret,
24
+ utc_now,
25
+ validate_channel_name,
26
+ validate_name,
27
+ )
28
+ from inter_agent.core.tls import TlsConfigError, build_server_ssl_context
29
+
30
+ DEFAULT_IDLE_TIMEOUT_S: float | None = None
31
+
32
+
33
+ @dataclass
34
+ class Conn:
35
+ ws: ServerConnection
36
+ session_id: str
37
+ name: str
38
+ role: str
39
+ label: str | None
40
+ capabilities: dict[str, object]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TargetResolution:
45
+ target: Conn | None
46
+ error_code: ErrorCode | None = None
47
+ message: str | None = None
48
+
49
+
50
+ class BusServer:
51
+ def __init__(
52
+ self,
53
+ host: str,
54
+ port: int,
55
+ limits: Limits | None = None,
56
+ idle_timeout_s: float | None = DEFAULT_IDLE_TIMEOUT_S,
57
+ ) -> None:
58
+ self.host = host
59
+ self.port = port
60
+ self.secret = resolve_shared_secret().secret
61
+ self.limits = limits or Limits()
62
+ self.registry: dict[str, Conn] = {}
63
+ self.channels: dict[str, set[str]] = {}
64
+ self.subscriptions: dict[str, set[str]] = {}
65
+ self.middlewares: list[RouterMiddleware] = []
66
+ self.shutdown_event = asyncio.Event()
67
+ self._lock = asyncio.Lock()
68
+ self._idle_timeout = idle_timeout_s
69
+ self._idle_timer: asyncio.Task[None] | None = None
70
+
71
+ def _cancel_idle_timer(self) -> None:
72
+ if self._idle_timer is not None:
73
+ self._idle_timer.cancel()
74
+ self._idle_timer = None
75
+
76
+ def _schedule_idle_timer(self) -> None:
77
+ if self._idle_timeout is None or self._idle_timeout <= 0:
78
+ return
79
+ self._cancel_idle_timer()
80
+ self._idle_timer = asyncio.create_task(self._start_idle_timer())
81
+
82
+ async def _start_idle_timer(self) -> None:
83
+ """Shut down after idle_timeout seconds with no connections when configured."""
84
+ timeout = self._idle_timeout
85
+ if timeout is None or timeout <= 0:
86
+ return
87
+ try:
88
+ # Use a CancelledError check below rather than watching shutdown_event,
89
+ # so we don't race with an external shutdown_set + new connection.
90
+ await asyncio.sleep(timeout)
91
+ except asyncio.CancelledError:
92
+ return
93
+ # If we weren't cancelled, nobody reconnected — time to stop.
94
+ self.shutdown_event.set()
95
+
96
+ async def send_error(self, ws: ServerConnection, code: ErrorCode, message: str) -> None:
97
+ await ws.send(json.dumps({"op": "error", "code": code.value, "message": message}))
98
+
99
+ async def read_object(
100
+ self, ws: ServerConnection, raw: str | bytes, frame_name: str
101
+ ) -> dict[str, object] | None:
102
+ try:
103
+ payload = json.loads(raw)
104
+ except (json.JSONDecodeError, UnicodeDecodeError):
105
+ await self.send_error(ws, ErrorCode.PROTOCOL_ERROR, f"{frame_name} must be valid JSON")
106
+ return None
107
+ if not isinstance(payload, dict):
108
+ await self.send_error(ws, ErrorCode.PROTOCOL_ERROR, f"{frame_name} must be an object")
109
+ return None
110
+ return {str(key): value for key, value in payload.items()}
111
+
112
+ async def handle(self, ws: ServerConnection) -> None:
113
+ session_id = None
114
+ self._cancel_idle_timer()
115
+ try:
116
+ raw = await ws.recv()
117
+ hello = await self.read_object(ws, raw, "first frame")
118
+ if hello is None:
119
+ return
120
+ if hello.get("op") != "hello":
121
+ await self.send_error(ws, ErrorCode.PROTOCOL_ERROR, "first op must be hello")
122
+ return
123
+
124
+ client_nonce = client_nonce_from_hello(hello)
125
+ if client_nonce is None:
126
+ await self.send_error(ws, ErrorCode.AUTH_FAILED, "invalid auth")
127
+ return
128
+ challenge = build_auth_challenge(
129
+ self.secret,
130
+ client_nonce=client_nonce,
131
+ hello=hello,
132
+ )
133
+ await ws.send(json.dumps(challenge))
134
+ response_raw = await ws.recv()
135
+ response = await self.read_object(ws, response_raw, "auth_response")
136
+ if response is None:
137
+ return
138
+ if response.get("op") != "auth_response":
139
+ await self.send_error(ws, ErrorCode.AUTH_FAILED, "invalid auth response")
140
+ return
141
+ client_proof_value = response.get("client_proof")
142
+ server_nonce_value = challenge.get("server_nonce")
143
+ if not isinstance(client_proof_value, str) or not isinstance(server_nonce_value, str):
144
+ await self.send_error(ws, ErrorCode.AUTH_FAILED, "invalid auth response")
145
+ return
146
+ if not verify_client_proof(
147
+ client_proof_value,
148
+ self.secret,
149
+ client_nonce=client_nonce,
150
+ server_nonce=server_nonce_value,
151
+ hello=hello,
152
+ ):
153
+ await self.send_error(ws, ErrorCode.AUTH_FAILED, "invalid auth")
154
+ return
155
+
156
+ role_value = hello.get("role")
157
+ session_id_value = hello.get("session_id")
158
+ name_value = hello.get("name")
159
+ label_value = hello.get("label")
160
+ if label_value is not None and not isinstance(label_value, str):
161
+ await self.send_error(ws, ErrorCode.BAD_LABEL, "label must be a string or null")
162
+ return
163
+ if not isinstance(role_value, str) or role_value not in {"agent", "control"}:
164
+ await self.send_error(ws, ErrorCode.BAD_ROLE, "role must be agent or control")
165
+ return
166
+ if not session_id_value or not isinstance(session_id_value, str):
167
+ await self.send_error(ws, ErrorCode.BAD_SESSION, "missing session_id")
168
+ return
169
+ raw_capabilities = hello.get("capabilities")
170
+ if not isinstance(raw_capabilities, dict):
171
+ await self.send_error(
172
+ ws, ErrorCode.PROTOCOL_ERROR, "capabilities must be an object"
173
+ )
174
+ return
175
+
176
+ role = role_value
177
+ async with self._lock:
178
+ if session_id_value in self.registry:
179
+ await self.send_error(ws, ErrorCode.SESSION_TAKEN, "session_id already active")
180
+ return
181
+ if len(self.registry) >= self.limits.connection_max:
182
+ await self.send_error(
183
+ ws, ErrorCode.TOO_MANY_CONNECTIONS, "connection limit reached"
184
+ )
185
+ return
186
+ session_id = session_id_value
187
+ label = label_value
188
+ if role == "agent":
189
+ if not validate_name(name_value):
190
+ await self.send_error(ws, ErrorCode.BAD_NAME, "invalid name")
191
+ return
192
+ assert isinstance(name_value, str)
193
+ assigned_name = name_value
194
+ if any(c.name == assigned_name for c in self.registry.values()):
195
+ await self.send_error(ws, ErrorCode.NAME_TAKEN, "name already in use")
196
+ return
197
+ elif isinstance(name_value, str) and name_value:
198
+ assigned_name = name_value
199
+ else:
200
+ assigned_name = f"control-{session_id[:6]}"
201
+
202
+ capabilities = {str(key): value for key, value in raw_capabilities.items()}
203
+ conn = Conn(
204
+ ws=ws,
205
+ session_id=session_id,
206
+ name=assigned_name,
207
+ role=role,
208
+ label=label,
209
+ capabilities=capabilities,
210
+ )
211
+ self.registry[session_id] = conn
212
+ await ws.send(
213
+ json.dumps(
214
+ {
215
+ "op": "welcome",
216
+ "session_id": session_id,
217
+ "assigned_name": conn.name,
218
+ "capabilities": {
219
+ "core": {"version": "0.1"},
220
+ "channels": True,
221
+ "rate_limit": False,
222
+ },
223
+ }
224
+ )
225
+ )
226
+
227
+ async for frame in ws:
228
+ msg = await self.read_object(ws, frame, "frame")
229
+ if msg is None:
230
+ return
231
+ op = msg.get("op")
232
+ if op == "ping":
233
+ await ws.send(json.dumps({"op": "pong"}))
234
+ elif op == "bye":
235
+ return
236
+ elif op == "shutdown":
237
+ await self._handle_shutdown(conn)
238
+ elif op == "kick":
239
+ await self._handle_kick(conn, msg)
240
+ elif op == "list":
241
+ sessions = [
242
+ {
243
+ "session_id": c.session_id,
244
+ "name": c.name,
245
+ "label": c.label,
246
+ }
247
+ for c in sorted(self.registry.values(), key=lambda conn: conn.name)
248
+ if c.role == "agent"
249
+ ]
250
+ await ws.send(json.dumps({"op": "list_ok", "sessions": sessions}))
251
+ elif op == "send":
252
+ await self._route_send(conn, msg)
253
+ elif op == "broadcast":
254
+ await self._route_broadcast(conn, msg)
255
+ elif op == "custom":
256
+ await self._route_custom(conn, msg)
257
+ elif op == "subscribe":
258
+ await self._route_subscribe(conn, msg)
259
+ elif op == "unsubscribe":
260
+ await self._route_unsubscribe(conn, msg)
261
+ elif op == "publish":
262
+ await self._route_publish(conn, msg)
263
+ elif op == "channels":
264
+ await self._route_channels(conn, msg)
265
+ else:
266
+ await self.send_error(ws, ErrorCode.UNKNOWN_OP, f"unsupported op: {op}")
267
+ except websockets.ConnectionClosed:
268
+ pass
269
+ finally:
270
+ if session_id:
271
+ await self._cleanup_session_subscriptions(session_id)
272
+ if session_id in self.registry:
273
+ self.registry.pop(session_id, None)
274
+ if not self.registry:
275
+ self._schedule_idle_timer()
276
+
277
+ async def _apply_middlewares(self, sender: Conn, msg: dict[str, object]) -> None:
278
+ for middleware in self.middlewares:
279
+ await middleware.before_route(sender.session_id, msg)
280
+
281
+ async def _handle_shutdown(self, sender: Conn) -> None:
282
+ if sender.role != "control":
283
+ await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "shutdown requires control role")
284
+ return
285
+ await sender.ws.send(json.dumps({"op": "shutdown_ok"}))
286
+ self.shutdown_event.set()
287
+
288
+ async def _handle_kick(self, sender: Conn, msg: dict[str, object]) -> None:
289
+ """Force-disconnect a registered session by name or session_id.
290
+
291
+ Control-only. Used to clear ghost or unwanted sessions without
292
+ restarting the whole server. Not exposed through host extension tools.
293
+ """
294
+ if sender.role != "control":
295
+ await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "kick requires control role")
296
+ return
297
+
298
+ target_name = msg.get("name")
299
+ target_session_id = msg.get("session_id")
300
+ target: Conn | None = None
301
+ async with self._lock:
302
+ if isinstance(target_session_id, str) and target_session_id:
303
+ target = self.registry.get(target_session_id)
304
+ elif isinstance(target_name, str) and target_name:
305
+ target = next(
306
+ (c for c in self.registry.values() if c.name == target_name),
307
+ None,
308
+ )
309
+ else:
310
+ await self.send_error(
311
+ sender.ws, ErrorCode.PROTOCOL_ERROR, "kick requires name or session_id"
312
+ )
313
+ return
314
+
315
+ if target is None:
316
+ await self.send_error(sender.ws, ErrorCode.UNKNOWN_TARGET, "unknown target")
317
+ return
318
+
319
+ # Kick targets only registered agent-role sessions. A control-role
320
+ # connection is rejected without being closed.
321
+ if target.role != "agent":
322
+ await self.send_error(
323
+ sender.ws, ErrorCode.BAD_ROLE, "kick targets agent sessions only"
324
+ )
325
+ return
326
+
327
+ # Remove before closing so the target's own handler finally block
328
+ # sees the session already gone and does not double-remove. Late
329
+ # cleanup by session_id cannot clobber a newer same-name connection
330
+ # claimed under a different session_id.
331
+ self.registry.pop(target.session_id, None)
332
+ for channel in list(self.subscriptions.pop(target.session_id, [])):
333
+ self.channels[channel].discard(target.session_id)
334
+ if not self.channels[channel]:
335
+ self.channels.pop(channel, None)
336
+ await sender.ws.send(
337
+ json.dumps(
338
+ {
339
+ "op": "kick_ok",
340
+ "name": target.name,
341
+ "session_id": target.session_id,
342
+ }
343
+ )
344
+ )
345
+
346
+ # Signal the target that it was removed, then close. Both are best
347
+ # effort: a target that raced closed first must not unwind the control
348
+ # request. The KICKED message carries no controller identity, secret, or
349
+ # private session metadata.
350
+ try:
351
+ await self.send_error(target.ws, ErrorCode.KICKED, "removed by kick")
352
+ except websockets.ConnectionClosed:
353
+ pass
354
+ try:
355
+ await target.ws.close(code=1000, reason="kicked")
356
+ except websockets.ConnectionClosed:
357
+ pass
358
+ if not self.registry:
359
+ self._schedule_idle_timer()
360
+
361
+ async def close_connections(self) -> None:
362
+ for conn in list(self.registry.values()):
363
+ await conn.ws.close(code=1001, reason="server shutdown")
364
+
365
+ def _resolve_target(self, target_name: object) -> TargetResolution:
366
+ if not isinstance(target_name, str) or not target_name:
367
+ return TargetResolution(
368
+ target=None,
369
+ error_code=ErrorCode.UNKNOWN_TARGET,
370
+ message=f"unknown target: {target_name}",
371
+ )
372
+
373
+ exact = next((c for c in self.registry.values() if c.name == target_name), None)
374
+ if exact is not None:
375
+ return TargetResolution(target=exact)
376
+
377
+ prefix_matches = [c for c in self.registry.values() if c.name.startswith(target_name)]
378
+ if len(prefix_matches) == 1:
379
+ return TargetResolution(target=prefix_matches[0])
380
+ if len(prefix_matches) > 1:
381
+ return TargetResolution(
382
+ target=None,
383
+ error_code=ErrorCode.AMBIGUOUS_TARGET,
384
+ message=f"ambiguous target: {target_name}",
385
+ )
386
+ return TargetResolution(
387
+ target=None,
388
+ error_code=ErrorCode.UNKNOWN_TARGET,
389
+ message=f"unknown target: {target_name}",
390
+ )
391
+
392
+ async def _send_resolution_error(self, sender: Conn, resolution: TargetResolution) -> None:
393
+ if resolution.error_code is None or resolution.message is None:
394
+ raise RuntimeError("target resolution did not include an error")
395
+ await self.send_error(sender.ws, resolution.error_code, resolution.message)
396
+
397
+ async def _route_send(self, sender: Conn, msg: dict[str, object]) -> None:
398
+ await self._apply_middlewares(sender, msg)
399
+ to = msg.get("to")
400
+ text = msg.get("text")
401
+ if not isinstance(text, str):
402
+ await self.send_error(sender.ws, ErrorCode.BAD_TEXT, "text must be a string")
403
+ return
404
+ if len(text.encode()) > self.limits.direct_text_max:
405
+ await self.send_error(sender.ws, ErrorCode.TEXT_TOO_LARGE, "direct message too large")
406
+ return
407
+ resolution = self._resolve_target(to)
408
+ if resolution.target is None:
409
+ await self._send_resolution_error(sender, resolution)
410
+ return
411
+ target = resolution.target
412
+ from_name = msg.get("from_name", sender.name)
413
+ if not isinstance(from_name, str):
414
+ await self.send_error(sender.ws, ErrorCode.BAD_FROM_NAME, "from_name must be a string")
415
+ return
416
+ await target.ws.send(
417
+ json.dumps(
418
+ {
419
+ "op": "msg",
420
+ "msg_id": next_msg_id(),
421
+ "from": sender.session_id,
422
+ "from_name": from_name,
423
+ "to": target.name,
424
+ "text": text,
425
+ "ts": utc_now(),
426
+ }
427
+ )
428
+ )
429
+
430
+ async def _route_broadcast(self, sender: Conn, msg: dict[str, object]) -> None:
431
+ await self._apply_middlewares(sender, msg)
432
+ text = msg.get("text")
433
+ if not isinstance(text, str):
434
+ await self.send_error(sender.ws, ErrorCode.BAD_TEXT, "text must be a string")
435
+ return
436
+ if len(text.encode()) > self.limits.broadcast_text_max:
437
+ await self.send_error(
438
+ sender.ws, ErrorCode.TEXT_TOO_LARGE, "broadcast message too large"
439
+ )
440
+ return
441
+ from_name = msg.get("from_name", sender.name)
442
+ if not isinstance(from_name, str):
443
+ await self.send_error(sender.ws, ErrorCode.BAD_FROM_NAME, "from_name must be a string")
444
+ return
445
+ payload = json.dumps(
446
+ {
447
+ "op": "msg",
448
+ "msg_id": next_msg_id(),
449
+ "from": sender.session_id,
450
+ "from_name": from_name,
451
+ "text": text,
452
+ "ts": utc_now(),
453
+ }
454
+ )
455
+ for conn in self.registry.values():
456
+ if conn.session_id == sender.session_id or conn.role != "agent":
457
+ continue
458
+ await conn.ws.send(payload)
459
+
460
+ async def _route_custom(self, sender: Conn, msg: dict[str, object]) -> None:
461
+ await self._apply_middlewares(sender, msg)
462
+ custom_type = msg.get("custom_type")
463
+ if not isinstance(custom_type, str) or not custom_type:
464
+ await self.send_error(sender.ws, ErrorCode.BAD_CUSTOM_TYPE, "custom_type required")
465
+ return
466
+ if len(custom_type.encode()) > self.limits.custom_type_max:
467
+ await self.send_error(sender.ws, ErrorCode.BAD_CUSTOM_TYPE, "custom_type too large")
468
+ return
469
+ custom_payload = msg.get("payload")
470
+ custom_payload_size = len(json.dumps(custom_payload, ensure_ascii=False).encode())
471
+ if custom_payload_size > self.limits.custom_payload_max:
472
+ await self.send_error(
473
+ sender.ws, ErrorCode.CUSTOM_PAYLOAD_TOO_LARGE, "custom payload too large"
474
+ )
475
+ return
476
+ payload = {
477
+ "op": "msg",
478
+ "msg_id": next_msg_id(),
479
+ "from": sender.session_id,
480
+ "from_name": sender.name,
481
+ "custom_type": custom_type,
482
+ "payload": custom_payload,
483
+ "ts": utc_now(),
484
+ }
485
+ if msg.get("to"):
486
+ resolution = self._resolve_target(msg.get("to"))
487
+ if resolution.target is None:
488
+ await self._send_resolution_error(sender, resolution)
489
+ return
490
+ target = resolution.target
491
+ payload["to"] = target.name
492
+ await target.ws.send(json.dumps(payload))
493
+ return
494
+ for conn in self.registry.values():
495
+ if conn.session_id == sender.session_id or conn.role != "agent":
496
+ continue
497
+ await conn.ws.send(json.dumps(payload))
498
+
499
+ async def _cleanup_session_subscriptions(self, session_id: str) -> None:
500
+ async with self._lock:
501
+ for channel in list(self.subscriptions.get(session_id, [])):
502
+ self.channels[channel].discard(session_id)
503
+ if not self.channels[channel]:
504
+ self.channels.pop(channel, None)
505
+ self.subscriptions.pop(session_id, None)
506
+
507
+ def _validate_channel_name(self, channel: object) -> str | None:
508
+ if not validate_channel_name(channel, self.limits.channel_name_max):
509
+ return None
510
+ return str(channel)
511
+
512
+ async def _route_subscribe(self, sender: Conn, msg: dict[str, object]) -> None:
513
+ if sender.role != "agent":
514
+ await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "subscribe requires agent role")
515
+ return
516
+ channel = self._validate_channel_name(msg.get("channel"))
517
+ if channel is None:
518
+ await self.send_error(sender.ws, ErrorCode.BAD_CHANNEL, "invalid channel name")
519
+ return
520
+ async with self._lock:
521
+ session_subs = self.subscriptions.setdefault(sender.session_id, set())
522
+ if channel in session_subs:
523
+ await sender.ws.send(json.dumps({"op": "subscribe_ok", "channel": channel}))
524
+ return
525
+ if len(session_subs) >= self.limits.subscriptions_max:
526
+ await self.send_error(
527
+ sender.ws, ErrorCode.CHANNEL_LIMIT_REACHED, "subscription limit reached"
528
+ )
529
+ return
530
+ is_new_channel = channel not in self.channels
531
+ if is_new_channel and len(self.channels) >= self.limits.channels_max:
532
+ await self.send_error(
533
+ sender.ws, ErrorCode.CHANNEL_LIMIT_REACHED, "server channel limit reached"
534
+ )
535
+ return
536
+ session_subs.add(channel)
537
+ self.channels.setdefault(channel, set()).add(sender.session_id)
538
+ await sender.ws.send(json.dumps({"op": "subscribe_ok", "channel": channel}))
539
+
540
+ async def _route_unsubscribe(self, sender: Conn, msg: dict[str, object]) -> None:
541
+ if sender.role != "agent":
542
+ await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "unsubscribe requires agent role")
543
+ return
544
+ channel = self._validate_channel_name(msg.get("channel"))
545
+ if channel is None:
546
+ await self.send_error(sender.ws, ErrorCode.BAD_CHANNEL, "invalid channel name")
547
+ return
548
+ async with self._lock:
549
+ session_subs = self.subscriptions.get(sender.session_id, set())
550
+ if channel not in session_subs:
551
+ await self.send_error(sender.ws, ErrorCode.NOT_SUBSCRIBED, "not subscribed")
552
+ return
553
+ session_subs.discard(channel)
554
+ if not session_subs:
555
+ self.subscriptions.pop(sender.session_id, None)
556
+ self.channels[channel].discard(sender.session_id)
557
+ if not self.channels[channel]:
558
+ self.channels.pop(channel, None)
559
+ await sender.ws.send(json.dumps({"op": "unsubscribe_ok", "channel": channel}))
560
+
561
+ async def _route_publish(self, sender: Conn, msg: dict[str, object]) -> None:
562
+ if sender.role not in {"agent", "control"}:
563
+ await self.send_error(
564
+ sender.ws, ErrorCode.BAD_ROLE, "publish requires agent or control role"
565
+ )
566
+ return
567
+ channel = self._validate_channel_name(msg.get("channel"))
568
+ if channel is None:
569
+ await self.send_error(sender.ws, ErrorCode.BAD_CHANNEL, "invalid channel name")
570
+ return
571
+ text = msg.get("text")
572
+ if not isinstance(text, str):
573
+ await self.send_error(sender.ws, ErrorCode.BAD_TEXT, "text must be a string")
574
+ return
575
+ if len(text.encode()) > self.limits.broadcast_text_max:
576
+ await self.send_error(sender.ws, ErrorCode.TEXT_TOO_LARGE, "publish text too large")
577
+ return
578
+ async with self._lock:
579
+ subscribers = self.channels.get(channel)
580
+ if not subscribers:
581
+ await self.send_error(sender.ws, ErrorCode.UNKNOWN_CHANNEL, "unknown channel")
582
+ return
583
+ from_name = msg.get("from_name", sender.name)
584
+ if not isinstance(from_name, str):
585
+ await self.send_error(
586
+ sender.ws, ErrorCode.BAD_FROM_NAME, "from_name must be a string"
587
+ )
588
+ return
589
+ payload = json.dumps(
590
+ {
591
+ "op": "msg",
592
+ "msg_id": next_msg_id(),
593
+ "from": sender.session_id,
594
+ "from_name": from_name,
595
+ "channel": channel,
596
+ "text": text,
597
+ "ts": utc_now(),
598
+ }
599
+ )
600
+ for session_id in subscribers:
601
+ if session_id == sender.session_id:
602
+ continue
603
+ conn = self.registry.get(session_id)
604
+ if conn is not None:
605
+ await conn.ws.send(payload)
606
+
607
+ async def _route_channels(self, sender: Conn, msg: dict[str, object]) -> None:
608
+ del msg
609
+ if sender.role != "control":
610
+ await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "channels requires control role")
611
+ return
612
+ async with self._lock:
613
+ result: list[dict[str, object]] = []
614
+ for channel in sorted(self.channels):
615
+ subscriber_ids = sorted(self.channels[channel])
616
+ names = sorted(
617
+ self.registry[sid].name for sid in subscriber_ids if sid in self.registry
618
+ )
619
+ result.append({"name": channel, "subscribers": names})
620
+ await sender.ws.send(json.dumps({"op": "channels_ok", "channels": result}))
621
+
622
+
623
+ def _is_websocket_upgrade(request: Request) -> bool:
624
+ """Return True when ``request`` carries a valid WebSocket upgrade."""
625
+ connection = [
626
+ token.strip().lower()
627
+ for value in request.headers.get_all("Connection")
628
+ for token in value.split(",")
629
+ ]
630
+ if "upgrade" not in connection:
631
+ return False
632
+ upgrade = [value.strip().lower() for value in request.headers.get_all("Upgrade")]
633
+ return upgrade == ["websocket"]
634
+
635
+
636
+ async def _process_request(connection: ServerConnection, request: Request) -> Response | None:
637
+ """Serve a friendly HTTP response for non-WebSocket requests.
638
+
639
+ Plain HTTP probes (e.g. ``curl http://host:port``) would otherwise make
640
+ the underlying library raise ``InvalidUpgrade`` and log a noisy traceback.
641
+ Returning a ``Response`` short-circuits the handshake before that happens
642
+ and keeps the server log quiet. WebSocket upgrades fall through to the
643
+ normal handshake by returning ``None``.
644
+ """
645
+ if _is_websocket_upgrade(request):
646
+ return None
647
+ headers = websockets.datastructures.Headers()
648
+ headers["Upgrade"] = "websocket"
649
+ headers["Content-Type"] = "text/plain; charset=utf-8"
650
+ body = b"This is an inter-agent WebSocket server.\n" b"Use a WebSocket client to connect.\n"
651
+ return Response(
652
+ http.HTTPStatus.UPGRADE_REQUIRED,
653
+ http.HTTPStatus.UPGRADE_REQUIRED.phrase,
654
+ headers,
655
+ body,
656
+ )
657
+
658
+
659
+ async def run_server(
660
+ host: str,
661
+ port: int,
662
+ limits: Limits | None = None,
663
+ idle_timeout_s: float | None = DEFAULT_IDLE_TIMEOUT_S,
664
+ *,
665
+ tls: bool = False,
666
+ data_dir: Path | None = None,
667
+ tls_cert_path: Path | None = None,
668
+ tls_key_path: Path | None = None,
669
+ ) -> None:
670
+ """Start the core WebSocket bus until the task is cancelled."""
671
+ server = BusServer(host=host, port=port, limits=limits, idle_timeout_s=idle_timeout_s)
672
+ ssl_context = None
673
+ if tls:
674
+ if data_dir is None:
675
+ raise TlsConfigError("TLS server startup requires a data directory")
676
+ ssl_context = build_server_ssl_context(
677
+ data_dir,
678
+ host,
679
+ tls_cert_path,
680
+ tls_key_path,
681
+ )
682
+ print(f"Starting inter-agent-server on {'wss' if tls else 'ws'}://{host}:{port}...")
683
+
684
+ def _request_shutdown() -> None:
685
+ print("\nShutting down inter-agent-server...")
686
+ server.shutdown_event.set()
687
+
688
+ loop = asyncio.get_running_loop()
689
+ for sig in (signal.SIGINT, signal.SIGTERM):
690
+ try:
691
+ loop.add_signal_handler(sig, _request_shutdown)
692
+ except (NotImplementedError, ValueError):
693
+ pass # Signal not supported on this platform
694
+
695
+ try:
696
+ async with websockets.serve(
697
+ server.handle,
698
+ host=host,
699
+ port=port,
700
+ max_size=server.limits.frame_max,
701
+ process_request=_process_request,
702
+ ssl=ssl_context,
703
+ ):
704
+ if not server.registry:
705
+ server._schedule_idle_timer()
706
+ await server.shutdown_event.wait()
707
+ server._cancel_idle_timer()
708
+ await server.close_connections()
709
+ finally:
710
+ for sig in (signal.SIGINT, signal.SIGTERM):
711
+ try:
712
+ loop.remove_signal_handler(sig)
713
+ except (NotImplementedError, ValueError):
714
+ pass
715
+
716
+
717
+ def build_parser() -> argparse.ArgumentParser:
718
+ parser = argparse.ArgumentParser(prog="inter-agent-server")
719
+ parser.add_argument("--host")
720
+ parser.add_argument("--port", type=int)
721
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
722
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
723
+ parser.add_argument("--tls-cert")
724
+ parser.add_argument("--tls-key")
725
+ parser.add_argument(
726
+ "--idle-timeout",
727
+ type=int,
728
+ default=None,
729
+ help="shut down after N seconds with no connections (default: disabled; 0 disables)",
730
+ )
731
+ return parser
732
+
733
+
734
+ def main(argv: Sequence[str] | None = None) -> int:
735
+ parser = build_parser()
736
+ args = parser.parse_args(argv)
737
+ endpoint = resolve_endpoint(
738
+ args.host,
739
+ args.port,
740
+ tls=args.tls,
741
+ tls_cert_path=args.tls_cert,
742
+ tls_key_path=args.tls_key,
743
+ )
744
+ try:
745
+ asyncio.run(
746
+ run_server(
747
+ endpoint.host,
748
+ endpoint.port,
749
+ idle_timeout_s=args.idle_timeout,
750
+ tls=endpoint.tls,
751
+ data_dir=endpoint.data_dir,
752
+ tls_cert_path=endpoint.tls_cert_path,
753
+ tls_key_path=endpoint.tls_key_path,
754
+ )
755
+ )
756
+ except TlsConfigError as exc:
757
+ raise SystemExit(f"could not start inter-agent-server TLS: {exc}") from exc
758
+ except OSError as exc:
759
+ message = f"could not start inter-agent-server on {endpoint.host}:{endpoint.port}: {exc}"
760
+ raise SystemExit(message) from exc
761
+ return 0
762
+
763
+
764
+ if __name__ == "__main__":
765
+ raise SystemExit(main())