agent-relay 3.2.2 → 3.2.4

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 (246) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +1358 -941
  6. package/dist/src/cli/commands/agent-management.d.ts +2 -2
  7. package/dist/src/cli/commands/agent-management.d.ts.map +1 -1
  8. package/dist/src/cli/commands/agent-management.js +41 -240
  9. package/dist/src/cli/commands/agent-management.js.map +1 -1
  10. package/dist/src/cli/commands/messaging.d.ts +1 -1
  11. package/dist/src/cli/commands/messaging.d.ts.map +1 -1
  12. package/dist/src/cli/commands/messaging.js +14 -5
  13. package/dist/src/cli/commands/messaging.js.map +1 -1
  14. package/dist/src/cli/lib/agent-management-listing.d.ts +4 -1
  15. package/dist/src/cli/lib/agent-management-listing.d.ts.map +1 -1
  16. package/dist/src/cli/lib/agent-management-listing.js +27 -2
  17. package/dist/src/cli/lib/agent-management-listing.js.map +1 -1
  18. package/package.json +11 -10
  19. package/packages/acp-bridge/package.json +2 -2
  20. package/packages/config/package.json +1 -1
  21. package/packages/hooks/package.json +4 -4
  22. package/packages/memory/package.json +2 -2
  23. package/packages/openclaw/package.json +2 -2
  24. package/packages/policy/package.json +2 -2
  25. package/packages/sdk/ADAPTER_REVIEW.md +109 -0
  26. package/packages/sdk/dist/client.d.ts +66 -0
  27. package/packages/sdk/dist/client.d.ts.map +1 -1
  28. package/packages/sdk/dist/client.js +230 -0
  29. package/packages/sdk/dist/client.js.map +1 -1
  30. package/packages/sdk/dist/communicate/a2a-bridge.d.ts +25 -0
  31. package/packages/sdk/dist/communicate/a2a-bridge.d.ts.map +1 -0
  32. package/packages/sdk/dist/communicate/a2a-bridge.js +89 -0
  33. package/packages/sdk/dist/communicate/a2a-bridge.js.map +1 -0
  34. package/packages/sdk/dist/communicate/a2a-server.d.ts +31 -0
  35. package/packages/sdk/dist/communicate/a2a-server.d.ts.map +1 -0
  36. package/packages/sdk/dist/communicate/a2a-server.js +220 -0
  37. package/packages/sdk/dist/communicate/a2a-server.js.map +1 -0
  38. package/packages/sdk/dist/communicate/a2a-transport.d.ts +48 -0
  39. package/packages/sdk/dist/communicate/a2a-transport.d.ts.map +1 -0
  40. package/packages/sdk/dist/communicate/a2a-transport.js +302 -0
  41. package/packages/sdk/dist/communicate/a2a-transport.js.map +1 -0
  42. package/packages/sdk/dist/communicate/a2a-types.d.ts +107 -0
  43. package/packages/sdk/dist/communicate/a2a-types.d.ts.map +1 -0
  44. package/packages/sdk/dist/communicate/a2a-types.js +209 -0
  45. package/packages/sdk/dist/communicate/a2a-types.js.map +1 -0
  46. package/packages/sdk/dist/communicate/adapters/claude-sdk.d.ts +28 -0
  47. package/packages/sdk/dist/communicate/adapters/claude-sdk.d.ts.map +1 -0
  48. package/packages/sdk/dist/communicate/adapters/claude-sdk.js +47 -0
  49. package/packages/sdk/dist/communicate/adapters/claude-sdk.js.map +1 -0
  50. package/packages/sdk/dist/communicate/adapters/crewai.d.ts +42 -0
  51. package/packages/sdk/dist/communicate/adapters/crewai.d.ts.map +1 -0
  52. package/packages/sdk/dist/communicate/adapters/crewai.js +95 -0
  53. package/packages/sdk/dist/communicate/adapters/crewai.js.map +1 -0
  54. package/packages/sdk/dist/communicate/adapters/google-adk.d.ts +53 -0
  55. package/packages/sdk/dist/communicate/adapters/google-adk.d.ts.map +1 -0
  56. package/packages/sdk/dist/communicate/adapters/google-adk.js +77 -0
  57. package/packages/sdk/dist/communicate/adapters/google-adk.js.map +1 -0
  58. package/packages/sdk/dist/communicate/adapters/index.d.ts +7 -0
  59. package/packages/sdk/dist/communicate/adapters/index.d.ts.map +1 -0
  60. package/packages/sdk/dist/communicate/adapters/index.js +7 -0
  61. package/packages/sdk/dist/communicate/adapters/index.js.map +1 -0
  62. package/packages/sdk/dist/communicate/adapters/langgraph.d.ts +40 -0
  63. package/packages/sdk/dist/communicate/adapters/langgraph.d.ts.map +1 -0
  64. package/packages/sdk/dist/communicate/adapters/langgraph.js +77 -0
  65. package/packages/sdk/dist/communicate/adapters/langgraph.js.map +1 -0
  66. package/packages/sdk/dist/communicate/adapters/openai-agents.d.ts +25 -0
  67. package/packages/sdk/dist/communicate/adapters/openai-agents.d.ts.map +1 -0
  68. package/packages/sdk/dist/communicate/adapters/openai-agents.js +70 -0
  69. package/packages/sdk/dist/communicate/adapters/openai-agents.js.map +1 -0
  70. package/packages/sdk/dist/communicate/adapters/pi.d.ts +45 -0
  71. package/packages/sdk/dist/communicate/adapters/pi.d.ts.map +1 -0
  72. package/packages/sdk/dist/communicate/adapters/pi.js +59 -0
  73. package/packages/sdk/dist/communicate/adapters/pi.js.map +1 -0
  74. package/packages/sdk/dist/communicate/core.d.ts +58 -0
  75. package/packages/sdk/dist/communicate/core.d.ts.map +1 -0
  76. package/packages/sdk/dist/communicate/core.js +128 -0
  77. package/packages/sdk/dist/communicate/core.js.map +1 -0
  78. package/packages/sdk/dist/communicate/index.d.ts +4 -0
  79. package/packages/sdk/dist/communicate/index.d.ts.map +1 -0
  80. package/packages/sdk/dist/communicate/index.js +4 -0
  81. package/packages/sdk/dist/communicate/index.js.map +1 -0
  82. package/packages/sdk/dist/communicate/transport.d.ts +36 -0
  83. package/packages/sdk/dist/communicate/transport.d.ts.map +1 -0
  84. package/packages/sdk/dist/communicate/transport.js +371 -0
  85. package/packages/sdk/dist/communicate/transport.js.map +1 -0
  86. package/packages/sdk/dist/communicate/types.d.ts +58 -0
  87. package/packages/sdk/dist/communicate/types.d.ts.map +1 -0
  88. package/packages/sdk/dist/communicate/types.js +66 -0
  89. package/packages/sdk/dist/communicate/types.js.map +1 -0
  90. package/packages/sdk/dist/workflows/builder.d.ts +35 -5
  91. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  92. package/packages/sdk/dist/workflows/builder.js +81 -7
  93. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  94. package/packages/sdk/dist/workflows/cli.js +14 -1
  95. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  96. package/packages/sdk/dist/workflows/runner.d.ts +10 -2
  97. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  98. package/packages/sdk/dist/workflows/runner.js +95 -1
  99. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  100. package/packages/sdk/dist/workflows/types.d.ts +11 -0
  101. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  102. package/packages/sdk/examples/communicate/claude_sdk_example.ts +5 -0
  103. package/packages/sdk/examples/communicate/pi_example.ts +8 -0
  104. package/packages/sdk/package.json +48 -2
  105. package/packages/sdk/src/__tests__/builder-deterministic.test.ts +132 -0
  106. package/packages/sdk/src/__tests__/communicate/a2a-bridge.test.ts +211 -0
  107. package/packages/sdk/src/__tests__/communicate/a2a-server.test.ts +359 -0
  108. package/packages/sdk/src/__tests__/communicate/a2a-transport.test.ts +537 -0
  109. package/packages/sdk/src/__tests__/communicate/a2a-types.test.ts +297 -0
  110. package/packages/sdk/src/__tests__/communicate/adapters/claude-sdk.test.ts +163 -0
  111. package/packages/sdk/src/__tests__/communicate/adapters/crewai.test.ts +219 -0
  112. package/packages/sdk/src/__tests__/communicate/adapters/e2e-crewai.test.ts +101 -0
  113. package/packages/sdk/src/__tests__/communicate/adapters/e2e-google-adk.test.ts +166 -0
  114. package/packages/sdk/src/__tests__/communicate/adapters/e2e-langgraph.test.ts +181 -0
  115. package/packages/sdk/src/__tests__/communicate/adapters/e2e-openai-agents.test.ts +137 -0
  116. package/packages/sdk/src/__tests__/communicate/adapters/e2e-pi.test.ts +140 -0
  117. package/packages/sdk/src/__tests__/communicate/adapters/google-adk.test.ts +200 -0
  118. package/packages/sdk/src/__tests__/communicate/adapters/langgraph.test.ts +162 -0
  119. package/packages/sdk/src/__tests__/communicate/adapters/openai-agents.test.ts +166 -0
  120. package/packages/sdk/src/__tests__/communicate/adapters/pi.test.ts +140 -0
  121. package/packages/sdk/src/__tests__/communicate/core.test.ts +574 -0
  122. package/packages/sdk/src/__tests__/communicate/integration/cross-framework.test.ts +353 -0
  123. package/packages/sdk/src/__tests__/communicate/transport.test.ts +613 -0
  124. package/packages/sdk/src/__tests__/start-from.test.ts +346 -0
  125. package/packages/sdk/src/client.ts +301 -0
  126. package/packages/sdk/src/communicate/a2a-bridge.ts +111 -0
  127. package/packages/sdk/src/communicate/a2a-server.ts +277 -0
  128. package/packages/sdk/src/communicate/a2a-transport.ts +395 -0
  129. package/packages/sdk/src/communicate/a2a-types.ts +338 -0
  130. package/packages/sdk/src/communicate/adapters/claude-sdk.ts +85 -0
  131. package/packages/sdk/src/communicate/adapters/crewai.ts +141 -0
  132. package/packages/sdk/src/communicate/adapters/google-adk.ts +139 -0
  133. package/packages/sdk/src/communicate/adapters/index.ts +6 -0
  134. package/packages/sdk/src/communicate/adapters/langgraph.ts +112 -0
  135. package/packages/sdk/src/communicate/adapters/openai-agents.ts +113 -0
  136. package/packages/sdk/src/communicate/adapters/pi.ts +105 -0
  137. package/packages/sdk/src/communicate/core.ts +157 -0
  138. package/packages/sdk/src/communicate/index.ts +3 -0
  139. package/packages/sdk/src/communicate/transport.ts +489 -0
  140. package/packages/sdk/src/communicate/types.ts +106 -0
  141. package/packages/sdk/src/examples/workflows/fix-dashboard-user-registration.yaml +182 -0
  142. package/packages/sdk/src/workflows/builder.ts +97 -9
  143. package/packages/sdk/src/workflows/cli.ts +16 -1
  144. package/packages/sdk/src/workflows/runner.ts +110 -1
  145. package/packages/sdk/src/workflows/types.ts +14 -0
  146. package/packages/sdk/tsconfig.build.json +1 -7
  147. package/packages/sdk/tsconfig.json +1 -7
  148. package/packages/sdk-py/README.md +67 -25
  149. package/packages/sdk-py/examples/communicate/agno_example.py +8 -0
  150. package/packages/sdk-py/examples/communicate/claude_sdk_example.py +6 -0
  151. package/packages/sdk-py/examples/communicate/crewai_example.py +7 -0
  152. package/packages/sdk-py/examples/communicate/google_adk_example.py +7 -0
  153. package/packages/sdk-py/examples/communicate/openai_agents_example.py +8 -0
  154. package/packages/sdk-py/examples/communicate/swarms_example.py +7 -0
  155. package/packages/sdk-py/pyproject.toml +12 -1
  156. package/packages/sdk-py/src/agent_relay/__init__.py +8 -0
  157. package/packages/sdk-py/src/agent_relay/builder.py +65 -26
  158. package/packages/sdk-py/src/agent_relay/communicate/__init__.py +6 -0
  159. package/packages/sdk-py/src/agent_relay/communicate/a2a_bridge.py +138 -0
  160. package/packages/sdk-py/src/agent_relay/communicate/a2a_server.py +242 -0
  161. package/packages/sdk-py/src/agent_relay/communicate/a2a_transport.py +366 -0
  162. package/packages/sdk-py/src/agent_relay/communicate/a2a_types.py +294 -0
  163. package/packages/sdk-py/src/agent_relay/communicate/adapters/__init__.py +10 -0
  164. package/packages/sdk-py/src/agent_relay/communicate/adapters/agno.py +74 -0
  165. package/packages/sdk-py/src/agent_relay/communicate/adapters/claude_sdk.py +78 -0
  166. package/packages/sdk-py/src/agent_relay/communicate/adapters/crewai.py +143 -0
  167. package/packages/sdk-py/src/agent_relay/communicate/adapters/google_adk.py +69 -0
  168. package/packages/sdk-py/src/agent_relay/communicate/adapters/openai_agents.py +86 -0
  169. package/packages/sdk-py/src/agent_relay/communicate/adapters/pi.py +175 -0
  170. package/packages/sdk-py/src/agent_relay/communicate/adapters/swarms.py +44 -0
  171. package/packages/sdk-py/src/agent_relay/communicate/core.py +293 -0
  172. package/packages/sdk-py/src/agent_relay/communicate/transport.py +502 -0
  173. package/packages/sdk-py/src/agent_relay/communicate/types.py +89 -0
  174. package/packages/sdk-py/src/agent_relay/types.py +2 -1
  175. package/packages/sdk-py/tests/communicate/__init__.py +0 -0
  176. package/packages/sdk-py/tests/communicate/adapters/__init__.py +0 -0
  177. package/packages/sdk-py/tests/communicate/adapters/e2e_test_agno.py +154 -0
  178. package/packages/sdk-py/tests/communicate/adapters/e2e_test_claude_sdk.py +428 -0
  179. package/packages/sdk-py/tests/communicate/adapters/e2e_test_crewai.py +234 -0
  180. package/packages/sdk-py/tests/communicate/adapters/e2e_test_google_adk.py +182 -0
  181. package/packages/sdk-py/tests/communicate/adapters/e2e_test_langgraph.py +262 -0
  182. package/packages/sdk-py/tests/communicate/adapters/e2e_test_openai_agents.py +88 -0
  183. package/packages/sdk-py/tests/communicate/adapters/e2e_test_pi.py +156 -0
  184. package/packages/sdk-py/tests/communicate/adapters/e2e_test_swarms.py +239 -0
  185. package/packages/sdk-py/tests/communicate/adapters/test_agno.py +140 -0
  186. package/packages/sdk-py/tests/communicate/adapters/test_claude_sdk.py +147 -0
  187. package/packages/sdk-py/tests/communicate/adapters/test_crewai.py +136 -0
  188. package/packages/sdk-py/tests/communicate/adapters/test_google_adk.py +125 -0
  189. package/packages/sdk-py/tests/communicate/adapters/test_openai_agents.py +99 -0
  190. package/packages/sdk-py/tests/communicate/adapters/test_pi.py +270 -0
  191. package/packages/sdk-py/tests/communicate/adapters/test_swarms.py +113 -0
  192. package/packages/sdk-py/tests/communicate/conftest.py +555 -0
  193. package/packages/sdk-py/tests/communicate/integration/__init__.py +1 -0
  194. package/packages/sdk-py/tests/communicate/integration/test_cross_framework.py +331 -0
  195. package/packages/sdk-py/tests/communicate/integration/test_end_to_end.py +151 -0
  196. package/packages/sdk-py/tests/communicate/test_a2a_bridge.py +363 -0
  197. package/packages/sdk-py/tests/communicate/test_a2a_server.py +346 -0
  198. package/packages/sdk-py/tests/communicate/test_a2a_transport.py +561 -0
  199. package/packages/sdk-py/tests/communicate/test_a2a_types.py +342 -0
  200. package/packages/sdk-py/tests/communicate/test_auto_detect.py +67 -0
  201. package/packages/sdk-py/tests/communicate/test_core.py +331 -0
  202. package/packages/sdk-py/tests/communicate/test_transport.py +373 -0
  203. package/packages/sdk-py/tests/communicate/test_types.py +285 -0
  204. package/packages/sdk-py/tests/test_builder_deterministic.py +118 -0
  205. package/packages/telemetry/package.json +1 -1
  206. package/packages/trajectory/package.json +2 -2
  207. package/packages/user-directory/package.json +2 -2
  208. package/packages/utils/package.json +2 -2
  209. package/packages/sdk/dist/__tests__/completion-pipeline.test.d.ts +0 -14
  210. package/packages/sdk/dist/__tests__/completion-pipeline.test.d.ts.map +0 -1
  211. package/packages/sdk/dist/__tests__/completion-pipeline.test.js +0 -1476
  212. package/packages/sdk/dist/__tests__/completion-pipeline.test.js.map +0 -1
  213. package/packages/sdk/dist/__tests__/contract-fixtures.test.d.ts +0 -2
  214. package/packages/sdk/dist/__tests__/contract-fixtures.test.d.ts.map +0 -1
  215. package/packages/sdk/dist/__tests__/contract-fixtures.test.js +0 -152
  216. package/packages/sdk/dist/__tests__/contract-fixtures.test.js.map +0 -1
  217. package/packages/sdk/dist/__tests__/e2e-owner-review.test.d.ts +0 -16
  218. package/packages/sdk/dist/__tests__/e2e-owner-review.test.d.ts.map +0 -1
  219. package/packages/sdk/dist/__tests__/e2e-owner-review.test.js +0 -640
  220. package/packages/sdk/dist/__tests__/e2e-owner-review.test.js.map +0 -1
  221. package/packages/sdk/dist/__tests__/facade.test.d.ts +0 -2
  222. package/packages/sdk/dist/__tests__/facade.test.d.ts.map +0 -1
  223. package/packages/sdk/dist/__tests__/facade.test.js +0 -305
  224. package/packages/sdk/dist/__tests__/facade.test.js.map +0 -1
  225. package/packages/sdk/dist/__tests__/integration.test.d.ts +0 -2
  226. package/packages/sdk/dist/__tests__/integration.test.d.ts.map +0 -1
  227. package/packages/sdk/dist/__tests__/integration.test.js +0 -205
  228. package/packages/sdk/dist/__tests__/integration.test.js.map +0 -1
  229. package/packages/sdk/dist/__tests__/pty.test.d.ts +0 -2
  230. package/packages/sdk/dist/__tests__/pty.test.d.ts.map +0 -1
  231. package/packages/sdk/dist/__tests__/pty.test.js +0 -20
  232. package/packages/sdk/dist/__tests__/pty.test.js.map +0 -1
  233. package/packages/sdk/dist/__tests__/quickstart.test.d.ts +0 -2
  234. package/packages/sdk/dist/__tests__/quickstart.test.d.ts.map +0 -1
  235. package/packages/sdk/dist/__tests__/quickstart.test.js +0 -176
  236. package/packages/sdk/dist/__tests__/quickstart.test.js.map +0 -1
  237. package/packages/sdk/dist/__tests__/spawn-from-env.test.d.ts +0 -2
  238. package/packages/sdk/dist/__tests__/spawn-from-env.test.d.ts.map +0 -1
  239. package/packages/sdk/dist/__tests__/spawn-from-env.test.js +0 -222
  240. package/packages/sdk/dist/__tests__/spawn-from-env.test.js.map +0 -1
  241. package/packages/sdk/dist/__tests__/unit.test.d.ts +0 -2
  242. package/packages/sdk/dist/__tests__/unit.test.d.ts.map +0 -1
  243. package/packages/sdk/dist/__tests__/unit.test.js +0 -357
  244. package/packages/sdk/dist/__tests__/unit.test.js.map +0 -1
  245. package/packages/sdk-py/agent_relay/__init__.py +0 -21
  246. package/packages/sdk-py/agent_relay/models.py +0 -398
@@ -0,0 +1,502 @@
1
+ """HTTP and WebSocket transport for communicate mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from contextlib import suppress
8
+ from inspect import isawaitable
9
+ from typing import Any
10
+
11
+ try:
12
+ import aiohttp
13
+ from aiohttp import WSMsgType
14
+ except ImportError:
15
+ raise ImportError(
16
+ "Communicate mode requires 'aiohttp'. "
17
+ "Install it with: pip install agent-relay-sdk[communicate]"
18
+ )
19
+
20
+ from .types import (
21
+ DEFAULT_RELAY_BASE_URL,
22
+ Message,
23
+ MessageCallback,
24
+ RelayAuthError,
25
+ RelayConfig,
26
+ RelayConfigError,
27
+ RelayConnectionError,
28
+ )
29
+
30
+ HTTP_RETRY_ATTEMPTS = 3
31
+ WS_RECONNECT_MAX_DELAY = 30
32
+
33
+
34
+ class RelayTransport:
35
+ """Minimal Relaycast transport backed by aiohttp."""
36
+
37
+ def __init__(self, agent_name: str, config: RelayConfig) -> None:
38
+ self.agent_name = agent_name
39
+ self.config = config
40
+ self.agent_id: str | None = None
41
+ self.token: str | None = None
42
+
43
+ self._session: aiohttp.ClientSession | None = None
44
+ self._ws: aiohttp.ClientWebSocketResponse | None = None
45
+ self._ws_task: asyncio.Task[None] | None = None
46
+ self._message_callback: MessageCallback | None = None
47
+ self._closing = False
48
+
49
+ async def connect(self) -> None:
50
+ self._require_config(require_workspace=True)
51
+ self._closing = False
52
+
53
+ await self.register_agent()
54
+ await self._connect_websocket()
55
+
56
+ if self._ws_task is None or self._ws_task.done():
57
+ self._ws_task = asyncio.create_task(self._ws_loop())
58
+
59
+ async def disconnect(self) -> None:
60
+ self._closing = True
61
+
62
+ ws_task = self._ws_task
63
+ self._ws_task = None
64
+
65
+ ws = self._ws
66
+ self._ws = None
67
+ if ws is not None and not ws.closed:
68
+ with suppress(Exception):
69
+ try:
70
+ await asyncio.wait_for(ws.close(), timeout=2)
71
+ except (asyncio.TimeoutError, asyncio.CancelledError):
72
+ pass
73
+
74
+ if ws_task is not None and not ws_task.done():
75
+ ws_task.cancel()
76
+ with suppress(asyncio.CancelledError):
77
+ await ws_task
78
+
79
+ with suppress(Exception):
80
+ await self.unregister_agent()
81
+
82
+ await self._close_session()
83
+ self._closing = False
84
+
85
+ async def send_http(
86
+ self,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ payload: dict[str, Any] | None = None,
91
+ ) -> Any:
92
+ self._require_config()
93
+ session = await self._ensure_session()
94
+ url = f"{self._base_url()}{path}"
95
+ headers = {"Authorization": f"Bearer {self.config.api_key}"}
96
+
97
+ for attempt in range(1, HTTP_RETRY_ATTEMPTS + 1):
98
+ try:
99
+ async with session.request(method, url, json=payload, headers=headers) as response:
100
+ if response.status == 401:
101
+ raise RelayAuthError(await self._error_message(response))
102
+
103
+ if 500 <= response.status <= 599:
104
+ message = await self._error_message(response)
105
+ if attempt < HTTP_RETRY_ATTEMPTS:
106
+ await asyncio.sleep(min(2 ** (attempt - 1), WS_RECONNECT_MAX_DELAY))
107
+ continue
108
+ raise RelayConnectionError(response.status, message)
109
+
110
+ if response.status >= 400:
111
+ raise RelayConnectionError(
112
+ response.status,
113
+ await self._error_message(response),
114
+ )
115
+
116
+ if response.status == 204:
117
+ return None
118
+
119
+ if response.content_type == "application/json":
120
+ return await response.json()
121
+
122
+ return await response.text()
123
+ except RelayAuthError:
124
+ raise
125
+ except RelayConnectionError:
126
+ raise
127
+ except aiohttp.ClientError as exc:
128
+ if attempt < HTTP_RETRY_ATTEMPTS:
129
+ await asyncio.sleep(min(2 ** (attempt - 1), WS_RECONNECT_MAX_DELAY))
130
+ continue
131
+ raise RelayConnectionError(0, str(exc)) from exc
132
+
133
+ raise RelayConnectionError(500, "Unexpected transport retry failure")
134
+
135
+ def on_ws_message(self, callback: MessageCallback) -> None:
136
+ self._message_callback = callback
137
+
138
+ async def register_agent(self) -> str:
139
+ """Register agent, or rotate token if it already exists (registerOrRotate pattern)."""
140
+ self._require_config(require_workspace=True)
141
+
142
+ if self.agent_id is not None and self.token is not None:
143
+ return self.agent_id
144
+
145
+ try:
146
+ payload = await self.send_http(
147
+ "POST",
148
+ "/v1/agents",
149
+ payload={"name": self.agent_name, "type": "agent"},
150
+ )
151
+ except RelayConnectionError as exc:
152
+ if exc.status_code == 409:
153
+ # Agent already exists — get its info and rotate the token
154
+ from urllib.parse import quote
155
+ agent_payload = await self.send_http(
156
+ "GET",
157
+ f"/v1/agents/{quote(self.agent_name, safe='')}",
158
+ )
159
+ agent_data = agent_payload.get("data", agent_payload)
160
+ self.agent_id = agent_data["id"]
161
+
162
+ rotate_payload = await self.send_http(
163
+ "POST",
164
+ f"/v1/agents/{quote(self.agent_name, safe='')}/rotate-token",
165
+ )
166
+ rotate_data = rotate_payload.get("data", rotate_payload)
167
+ self.token = rotate_data["token"]
168
+ return self.agent_id
169
+ raise
170
+ # Relaycast API wraps in {ok, data: {...}}
171
+ data = payload.get("data", payload)
172
+ self.agent_id = data["id"]
173
+ self.token = data["token"]
174
+ return self.agent_id
175
+
176
+ async def unregister_agent(self) -> None:
177
+ if self.agent_id is None or self.token is None:
178
+ await self._close_session_if_idle()
179
+ return
180
+
181
+ await self._send_http_as_agent("POST", "/v1/agents/disconnect")
182
+ self.agent_id = None
183
+ self.token = None
184
+ await self._close_session_if_idle()
185
+
186
+ async def send_dm(self, recipient: str, text: str) -> str:
187
+ await self._ensure_registered()
188
+ payload = await self._send_http_as_agent(
189
+ "POST",
190
+ "/v1/dm",
191
+ payload={"to": recipient, "text": text},
192
+ )
193
+ if payload is None:
194
+ return ""
195
+ data = payload.get("data", payload)
196
+ return data.get("id", data.get("message_id", ""))
197
+
198
+ async def post_message(self, channel: str, text: str) -> str:
199
+ await self._ensure_registered()
200
+ from urllib.parse import quote
201
+
202
+ payload = await self._send_http_as_agent(
203
+ "POST",
204
+ f"/v1/channels/{quote(channel, safe='')}/messages",
205
+ payload={"text": text},
206
+ )
207
+ if payload is None:
208
+ return ""
209
+ data = payload.get("data", payload)
210
+ return data.get("id", data.get("message_id", ""))
211
+
212
+ async def reply(self, message_id: str, text: str) -> str:
213
+ await self._ensure_registered()
214
+ from urllib.parse import quote
215
+
216
+ payload = await self._send_http_as_agent(
217
+ "POST",
218
+ f"/v1/messages/{quote(message_id, safe='')}/replies",
219
+ payload={"text": text},
220
+ )
221
+ if payload is None:
222
+ return ""
223
+ data = payload.get("data", payload)
224
+ return data.get("id", data.get("message_id", ""))
225
+
226
+
227
+ async def join_channel(self, channel: str) -> None:
228
+ await self._ensure_registered()
229
+ from urllib.parse import quote
230
+ await self._send_http_as_agent(
231
+ 'POST',
232
+ f'/v1/channels/{quote(channel, safe="")}/join',
233
+ )
234
+
235
+ async def check_inbox(self) -> list[Message]:
236
+ await self._ensure_registered()
237
+ from urllib.parse import quote
238
+
239
+ payload = await self._send_http_as_agent("GET", "/v1/inbox")
240
+ data = payload.get("data", payload)
241
+ messages: list[Message] = []
242
+
243
+ # Fetch unread DM conversations
244
+ for dm in data.get("unread_dms", []):
245
+ conv_id = dm.get("conversation_id", "")
246
+ sender = dm.get("from", "unknown")
247
+ # Fetch actual messages from the conversation
248
+ try:
249
+ conv_payload = await self._send_http_as_agent(
250
+ "GET", f"/v1/dm/{quote(conv_id, safe='')}/messages"
251
+ )
252
+ conv_data = conv_payload.get("data", conv_payload)
253
+ items = conv_data if isinstance(conv_data, list) else []
254
+ for item in items:
255
+ messages.append(Message(
256
+ sender=item.get("agent_name", sender),
257
+ text=item.get("text", ""),
258
+ channel=None,
259
+ thread_id=conv_id,
260
+ timestamp=item.get("created_at"),
261
+ message_id=item.get("id"),
262
+ ))
263
+ except Exception:
264
+ # Fall back to the summary last_message
265
+ last = dm.get("last_message", {})
266
+ if last.get("text"):
267
+ messages.append(Message(
268
+ sender=sender,
269
+ text=last["text"],
270
+ channel=None,
271
+ thread_id=conv_id,
272
+ timestamp=last.get("created_at"),
273
+ message_id=last.get("id"),
274
+ ))
275
+
276
+ # Also include unread channel mentions
277
+ for mention in data.get("mentions", []):
278
+ messages.append(Message(
279
+ sender=mention.get("from", mention.get("agent_name", "unknown")),
280
+ text=mention.get("text", ""),
281
+ channel=mention.get("channel_name"),
282
+ thread_id=mention.get("thread_id"),
283
+ timestamp=mention.get("created_at"),
284
+ message_id=mention.get("id"),
285
+ ))
286
+
287
+ return messages
288
+
289
+ async def list_agents(self) -> list[str]:
290
+ payload = await self.send_http("GET", "/v1/agents")
291
+ data = payload.get("data", payload)
292
+ if isinstance(data, list):
293
+ return [a.get("name", a) if isinstance(a, dict) else a for a in data]
294
+ return list(data.get("agents", []))
295
+
296
+ async def _send_http_as_agent(
297
+ self,
298
+ method: str,
299
+ path: str,
300
+ *,
301
+ payload: dict[str, Any] | None = None,
302
+ ) -> Any:
303
+ """Like send_http but authenticates with the per-agent token."""
304
+ await self._ensure_registered()
305
+ session = await self._ensure_session()
306
+ url = f"{self._base_url()}{path}"
307
+ headers = {"Authorization": f"Bearer {self.token}"}
308
+
309
+ for attempt in range(1, HTTP_RETRY_ATTEMPTS + 1):
310
+ try:
311
+ async with session.request(method, url, json=payload, headers=headers) as response:
312
+ if response.status == 401:
313
+ raise RelayAuthError(await self._error_message(response))
314
+
315
+ if 500 <= response.status <= 599:
316
+ message = await self._error_message(response)
317
+ if attempt < HTTP_RETRY_ATTEMPTS:
318
+ await asyncio.sleep(min(2 ** (attempt - 1), WS_RECONNECT_MAX_DELAY))
319
+ continue
320
+ raise RelayConnectionError(response.status, message)
321
+
322
+ if response.status >= 400:
323
+ raise RelayConnectionError(
324
+ response.status,
325
+ await self._error_message(response),
326
+ )
327
+
328
+ if response.status == 204:
329
+ return None
330
+
331
+ if response.content_type == "application/json":
332
+ return await response.json()
333
+
334
+ return await response.text()
335
+ except (RelayAuthError, RelayConnectionError):
336
+ raise
337
+ except aiohttp.ClientError as exc:
338
+ if attempt < HTTP_RETRY_ATTEMPTS:
339
+ await asyncio.sleep(min(2 ** (attempt - 1), WS_RECONNECT_MAX_DELAY))
340
+ continue
341
+ raise RelayConnectionError(0, str(exc)) from exc
342
+
343
+ raise RelayConnectionError(500, "Unexpected transport retry failure")
344
+
345
+ async def _ensure_registered(self) -> None:
346
+ if self.agent_id is None or self.token is None:
347
+ await self.register_agent()
348
+
349
+ def _require_config(self, *, require_workspace: bool = False) -> None:
350
+ if not self.config.api_key:
351
+ raise RelayConfigError(
352
+ "Missing RELAY_API_KEY. Set the environment variable or pass api_key= to RelayConfig."
353
+ )
354
+ if require_workspace and not self.config.workspace:
355
+ raise RelayConfigError(
356
+ "Missing RELAY_WORKSPACE. Set the environment variable or pass workspace= to RelayConfig."
357
+ )
358
+
359
+ def _base_url(self) -> str:
360
+ return (self.config.base_url or DEFAULT_RELAY_BASE_URL).rstrip("/")
361
+
362
+ async def _ensure_session(self) -> aiohttp.ClientSession:
363
+ if self._session is None or self._session.closed:
364
+ self._session = aiohttp.ClientSession()
365
+ return self._session
366
+
367
+ async def _close_session(self) -> None:
368
+ if self._session is not None and not self._session.closed:
369
+ await self._session.close()
370
+ self._session = None
371
+
372
+ async def _close_session_if_idle(self) -> None:
373
+ if self._ws_task is None and (self._ws is None or self._ws.closed):
374
+ await self._close_session()
375
+
376
+ async def _connect_websocket(self) -> None:
377
+ await self._ensure_registered()
378
+
379
+ if self._ws is not None and not self._ws.closed:
380
+ return
381
+
382
+ from urllib.parse import quote
383
+
384
+ session = await self._ensure_session()
385
+ ws_url = f"{self._ws_base_url()}/v1/ws?token={quote(self.token, safe='')}"
386
+ self._ws = await session.ws_connect(ws_url)
387
+
388
+ def _ws_base_url(self) -> str:
389
+ base_url = self._base_url()
390
+ if base_url.startswith("https://"):
391
+ return "wss://" + base_url[len("https://") :]
392
+ if base_url.startswith("http://"):
393
+ return "ws://" + base_url[len("http://") :]
394
+ return base_url
395
+
396
+ async def _ws_loop(self) -> None:
397
+ delay = 1
398
+
399
+ while not self._closing:
400
+ try:
401
+ if self._ws is None:
402
+ await self._connect_websocket()
403
+
404
+ assert self._ws is not None
405
+ async for raw_message in self._ws:
406
+ if raw_message.type is WSMsgType.TEXT:
407
+ await self._dispatch_ws_payload(raw_message.data)
408
+ elif raw_message.type in {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR}:
409
+ break
410
+ except asyncio.CancelledError:
411
+ raise
412
+ except Exception:
413
+ pass
414
+ finally:
415
+ if self._ws is not None and self._ws.closed:
416
+ self._ws = None
417
+
418
+ if self._closing:
419
+ break
420
+
421
+ await asyncio.sleep(delay)
422
+ delay = min(delay * 2, WS_RECONNECT_MAX_DELAY)
423
+
424
+ try:
425
+ await self._connect_websocket()
426
+ delay = 1
427
+ except asyncio.CancelledError:
428
+ raise
429
+ except Exception:
430
+ continue
431
+
432
+ async def _dispatch_ws_payload(self, raw_payload: str) -> None:
433
+ payload = json.loads(raw_payload)
434
+ event_type = payload.get("type", "")
435
+
436
+ if event_type == "ping":
437
+ if self._ws is not None and not self._ws.closed:
438
+ await self._ws.send_json({"type": "pong"})
439
+ return
440
+
441
+ # Accept message.created, dm.received, direct_message.received, thread.reply, and legacy "message"
442
+ message_events = {"message.created", "dm.received", "direct_message.received",
443
+ "thread.reply", "message", "group_dm.received"}
444
+ if event_type not in message_events:
445
+ return
446
+
447
+ callback = self._message_callback
448
+ if callback is None:
449
+ return
450
+
451
+ try:
452
+ msg = self._message_from_payload(payload)
453
+ except (KeyError, TypeError):
454
+ return
455
+
456
+ result = callback(msg)
457
+ if isawaitable(result):
458
+ await result
459
+
460
+ @staticmethod
461
+ def _message_from_payload(payload: dict[str, Any]) -> Message:
462
+ # Support both flat and nested message structures
463
+ m = payload.get("message") if isinstance(payload.get("message"), dict) else payload
464
+ sender = (
465
+ m.get("sender")
466
+ or m.get("agent_name")
467
+ or m.get("from")
468
+ or m.get("agentName")
469
+ or payload.get("agent_name")
470
+ or payload.get("from")
471
+ or "unknown"
472
+ )
473
+ text = m.get("text", "")
474
+ channel = m.get("channel") or m.get("channel_name") or m.get("channelName") or payload.get("channel") or payload.get("channel_name")
475
+ thread_id = m.get("thread_id") or m.get("threadId") or m.get("conversation_id") or m.get("conversationId") or payload.get("thread_id")
476
+ timestamp = m.get("timestamp") or m.get("created_at") or m.get("createdAt") or payload.get("timestamp")
477
+ message_id = m.get("id") or m.get("message_id") or m.get("messageId") or payload.get("message_id")
478
+
479
+ return Message(
480
+ sender=sender,
481
+ text=text,
482
+ channel=channel,
483
+ thread_id=thread_id,
484
+ timestamp=timestamp,
485
+ message_id=message_id,
486
+ )
487
+
488
+ @staticmethod
489
+ async def _error_message(response: aiohttp.ClientResponse) -> str:
490
+ try:
491
+ payload = await response.json()
492
+ except Exception:
493
+ text = await response.text()
494
+ return text or response.reason or "Request failed"
495
+ # Relaycast wraps errors as {ok: false, error: {code, message}}
496
+ error = payload.get("error")
497
+ if isinstance(error, dict) and error.get("message"):
498
+ return str(error["message"])
499
+ return str(payload.get("message") or response.reason or "Request failed")
500
+
501
+
502
+ __all__ = ["RelayTransport"]
@@ -0,0 +1,89 @@
1
+ """Types shared by the communicate-mode relay client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Awaitable, Callable, TypeAlias
8
+
9
+ DEFAULT_RELAY_BASE_URL = "https://api.relaycast.dev"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Message:
14
+ sender: str
15
+ text: str
16
+ channel: str | None = None
17
+ thread_id: str | None = None
18
+ timestamp: str | float | None = None
19
+ message_id: str | None = None
20
+
21
+
22
+ MessageCallback: TypeAlias = Callable[[Message], None] | Callable[[Message], Awaitable[None]]
23
+
24
+
25
+ @dataclass
26
+ class RelayConfig:
27
+ workspace: str | None = None
28
+ api_key: str | None = None
29
+ base_url: str | None = None
30
+ channels: list[str] = field(default_factory=lambda: ["general"])
31
+ poll_interval_ms: int = 1000
32
+ auto_cleanup: bool = True
33
+
34
+ def __post_init__(self) -> None:
35
+ if self.workspace is None:
36
+ self.workspace = os.getenv("RELAY_WORKSPACE")
37
+ if self.api_key is None:
38
+ self.api_key = os.getenv("RELAY_API_KEY")
39
+ if self.base_url is None:
40
+ self.base_url = os.getenv("RELAY_BASE_URL")
41
+
42
+ @classmethod
43
+ def resolve(
44
+ cls,
45
+ workspace: str | None = None,
46
+ api_key: str | None = None,
47
+ base_url: str | None = None,
48
+ channels: list[str] | None = None,
49
+ poll_interval_ms: int = 1000,
50
+ auto_cleanup: bool = True,
51
+ ) -> "RelayConfig":
52
+ resolved_workspace = workspace if workspace is not None else os.getenv("RELAY_WORKSPACE")
53
+ resolved_api_key = api_key if api_key is not None else os.getenv("RELAY_API_KEY")
54
+ resolved_base_url = base_url if base_url is not None else os.getenv("RELAY_BASE_URL") or DEFAULT_RELAY_BASE_URL
55
+ return cls(
56
+ workspace=resolved_workspace,
57
+ api_key=resolved_api_key,
58
+ base_url=resolved_base_url,
59
+ channels=list(channels) if channels is not None else ["general"],
60
+ poll_interval_ms=poll_interval_ms,
61
+ auto_cleanup=auto_cleanup,
62
+ )
63
+
64
+
65
+ class RelayConnectionError(Exception):
66
+ def __init__(self, status_code: int, message: str) -> None:
67
+ self.status_code = status_code
68
+ self.message = message
69
+ super().__init__(f"{status_code}: {message}")
70
+
71
+
72
+ class RelayConfigError(Exception):
73
+ pass
74
+
75
+
76
+ class RelayAuthError(RelayConnectionError):
77
+ def __init__(self, message: str = "Unauthorized", status_code: int = 401) -> None:
78
+ super().__init__(status_code=status_code, message=message)
79
+
80
+
81
+ __all__ = [
82
+ "DEFAULT_RELAY_BASE_URL",
83
+ "Message",
84
+ "MessageCallback",
85
+ "RelayAuthError",
86
+ "RelayConfig",
87
+ "RelayConfigError",
88
+ "RelayConnectionError",
89
+ ]
@@ -29,9 +29,10 @@ SwarmPattern = Literal[
29
29
  "blackboard",
30
30
  "swarm",
31
31
  "competitive",
32
+ "review-loop",
32
33
  ]
33
34
 
34
- AgentCli = Literal["claude", "codex", "gemini", "aider", "goose", "opencode", "droid"]
35
+ AgentCli = Literal["claude", "codex", "gemini", "aider", "goose", "opencode", "droid", "cursor", "cursor-agent", "agent"]
35
36
  AgentStatus = Literal["healthy", "restarting", "dead", "released"]
36
37
  CrashCategory = Literal["oom", "segfault", "error", "signal", "unknown"]
37
38
  WorkflowOnError = Literal["fail", "skip", "retry"]
File without changes