agent-relay 3.2.14 → 3.2.16
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/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/dist/index.cjs +3859 -17164
- package/package.json +8 -8
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/sdk/dist/broker-path.d.ts +19 -0
- package/packages/sdk/dist/broker-path.d.ts.map +1 -0
- package/packages/sdk/dist/broker-path.js +71 -0
- package/packages/sdk/dist/broker-path.js.map +1 -0
- package/packages/sdk/dist/cli-registry.d.ts.map +1 -1
- package/packages/sdk/dist/cli-registry.js +10 -1
- package/packages/sdk/dist/cli-registry.js.map +1 -1
- package/packages/sdk/dist/client.d.ts +6 -1
- package/packages/sdk/dist/client.d.ts.map +1 -1
- package/packages/sdk/dist/client.js +18 -0
- package/packages/sdk/dist/client.js.map +1 -1
- package/packages/sdk/dist/communicate/adapters/index.d.ts +0 -5
- package/packages/sdk/dist/communicate/adapters/index.d.ts.map +1 -1
- package/packages/sdk/dist/communicate/adapters/index.js +0 -5
- package/packages/sdk/dist/communicate/adapters/index.js.map +1 -1
- package/packages/sdk/dist/communicate/adapters/pi.d.ts +0 -1
- package/packages/sdk/dist/communicate/adapters/pi.d.ts.map +1 -1
- package/packages/sdk/dist/communicate/adapters/pi.js +0 -4
- package/packages/sdk/dist/communicate/adapters/pi.js.map +1 -1
- package/packages/sdk/dist/communicate/core.d.ts.map +1 -1
- package/packages/sdk/dist/communicate/core.js +2 -3
- package/packages/sdk/dist/communicate/core.js.map +1 -1
- package/packages/sdk/dist/communicate/index.d.ts +17 -1
- package/packages/sdk/dist/communicate/index.d.ts.map +1 -1
- package/packages/sdk/dist/communicate/index.js +40 -1
- package/packages/sdk/dist/communicate/index.js.map +1 -1
- package/packages/sdk/dist/communicate/transport.d.ts +0 -1
- package/packages/sdk/dist/communicate/transport.d.ts.map +1 -1
- package/packages/sdk/dist/communicate/transport.js +42 -134
- package/packages/sdk/dist/communicate/transport.js.map +1 -1
- package/packages/sdk/dist/http.d.ts +38 -0
- package/packages/sdk/dist/http.d.ts.map +1 -0
- package/packages/sdk/dist/http.js +60 -0
- package/packages/sdk/dist/http.js.map +1 -0
- package/packages/sdk/dist/protocol.d.ts +25 -0
- package/packages/sdk/dist/protocol.d.ts.map +1 -1
- package/packages/sdk/dist/relay.d.ts +26 -3
- package/packages/sdk/dist/relay.d.ts.map +1 -1
- package/packages/sdk/dist/relay.js +62 -4
- package/packages/sdk/dist/relay.js.map +1 -1
- package/packages/sdk/dist/workflows/api-executor.d.ts +16 -0
- package/packages/sdk/dist/workflows/api-executor.d.ts.map +1 -0
- package/packages/sdk/dist/workflows/api-executor.js +94 -0
- package/packages/sdk/dist/workflows/api-executor.js.map +1 -0
- package/packages/sdk/dist/workflows/builder.d.ts +14 -0
- package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/builder.js +26 -0
- package/packages/sdk/dist/workflows/builder.js.map +1 -1
- package/packages/sdk/dist/workflows/cloud-runner.d.ts +15 -0
- package/packages/sdk/dist/workflows/cloud-runner.d.ts.map +1 -0
- package/packages/sdk/dist/workflows/cloud-runner.js +41 -0
- package/packages/sdk/dist/workflows/cloud-runner.js.map +1 -0
- package/packages/sdk/dist/workflows/index.d.ts +2 -0
- package/packages/sdk/dist/workflows/index.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/index.js +1 -0
- package/packages/sdk/dist/workflows/index.js.map +1 -1
- package/packages/sdk/dist/workflows/run.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/run.js +4 -0
- package/packages/sdk/dist/workflows/run.js.map +1 -1
- package/packages/sdk/dist/workflows/runner.d.ts +14 -0
- package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/runner.js +154 -10
- package/packages/sdk/dist/workflows/runner.js.map +1 -1
- package/packages/sdk/dist/workflows/types.d.ts +13 -3
- package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/types.js +5 -1
- package/packages/sdk/dist/workflows/types.js.map +1 -1
- package/packages/sdk/dist/workflows/validator.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/validator.js +12 -0
- package/packages/sdk/dist/workflows/validator.js.map +1 -1
- package/packages/sdk/package.json +13 -3
- package/packages/sdk/src/__tests__/channel-management.test.ts +131 -0
- package/packages/sdk/src/__tests__/communicate/core.test.ts +36 -88
- package/packages/sdk/src/__tests__/communicate/transport.test.ts +41 -80
- package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +120 -0
- package/packages/sdk/src/__tests__/relay-channel-ops.test.ts +121 -0
- package/packages/sdk/src/__tests__/workflow-runner.test.ts +2 -2
- package/packages/sdk/src/broker-path.ts +74 -0
- package/packages/sdk/src/cli-registry.ts +10 -1
- package/packages/sdk/src/client.ts +28 -0
- package/packages/sdk/src/communicate/adapters/index.ts +0 -5
- package/packages/sdk/src/communicate/adapters/pi.ts +1 -5
- package/packages/sdk/src/communicate/core.ts +6 -10
- package/packages/sdk/src/communicate/index.ts +57 -1
- package/packages/sdk/src/communicate/transport.ts +46 -177
- package/packages/sdk/src/http.ts +96 -0
- package/packages/sdk/src/protocol.ts +24 -0
- package/packages/sdk/src/relay.ts +93 -8
- package/packages/sdk/src/workflows/README.md +5 -2
- package/packages/sdk/src/workflows/api-executor.ts +108 -0
- package/packages/sdk/src/workflows/builder.ts +40 -0
- package/packages/sdk/src/workflows/cloud-runner.ts +56 -0
- package/packages/sdk/src/workflows/index.ts +2 -0
- package/packages/sdk/src/workflows/run.ts +5 -0
- package/packages/sdk/src/workflows/runner.ts +181 -11
- package/packages/sdk/src/workflows/types.ts +19 -4
- package/packages/sdk/src/workflows/validator.ts +15 -0
- package/packages/sdk-py/README.md +7 -0
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/sdk-py/src/agent_relay/__init__.py +2 -0
- package/packages/sdk-py/src/agent_relay/client.py +4 -0
- package/packages/sdk-py/src/agent_relay/communicate/adapters/__init__.py +0 -9
- package/packages/sdk-py/src/agent_relay/communicate/adapters/agno.py +5 -9
- package/packages/sdk-py/src/agent_relay/communicate/adapters/claude_sdk.py +5 -7
- package/packages/sdk-py/src/agent_relay/communicate/adapters/crewai.py +3 -13
- package/packages/sdk-py/src/agent_relay/communicate/adapters/google_adk.py +5 -2
- package/packages/sdk-py/src/agent_relay/communicate/adapters/openai_agents.py +5 -9
- package/packages/sdk-py/src/agent_relay/communicate/core.py +7 -24
- package/packages/sdk-py/src/agent_relay/communicate/transport.py +35 -212
- package/packages/sdk-py/src/agent_relay/communicate/types.py +1 -1
- package/packages/sdk-py/src/agent_relay/protocol.py +1 -0
- package/packages/sdk-py/src/agent_relay/relay.py +9 -1
- package/packages/sdk-py/tests/communicate/adapters/test_claude_sdk.py +6 -6
- package/packages/sdk-py/tests/communicate/conftest.py +86 -233
- package/packages/sdk-py/tests/communicate/integration/test_cross_framework.py +2 -2
- package/packages/sdk-py/tests/communicate/integration/test_end_to_end.py +14 -24
- package/packages/sdk-py/tests/communicate/test_transport.py +65 -54
- package/packages/sdk-py/tests/test_send_message_mode.py +91 -0
- package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserver.swift +323 -0
- package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserverTypes.swift +143 -0
- package/packages/sdk-swift/Tests/AgentRelaySDKTests/RelayObserverTests.swift +526 -0
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Wave 1.2 tests for the RelayTransport HTTP/WS client."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -44,8 +44,10 @@ async def test_register_agent_and_unregister_agent_manage_identity(relay_server)
|
|
|
44
44
|
|
|
45
45
|
assert transport.agent_id in relay_server.registered_agents
|
|
46
46
|
assert transport.token == relay_server.registered_agents[transport.agent_id]["token"]
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
assert relay_server.requests["register_agent"][-1]["json"] == {
|
|
48
|
+
"name": "TransportTester",
|
|
49
|
+
"workspace": relay_server.workspace,
|
|
50
|
+
}
|
|
49
51
|
|
|
50
52
|
agent_id = transport.agent_id
|
|
51
53
|
await transport.unregister_agent()
|
|
@@ -75,55 +77,47 @@ async def test_connect_and_disconnect_manage_registration_and_websocket(relay_se
|
|
|
75
77
|
|
|
76
78
|
|
|
77
79
|
@pytest.mark.asyncio
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
assert ch_req["json"]["text"] == "status update"
|
|
109
|
-
assert "/v1/channels/core-py/messages" in ch_req["path"]
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
@pytest.mark.asyncio
|
|
113
|
-
async def test_reply_posts_to_replies_endpoint(relay_server):
|
|
80
|
+
@pytest.mark.parametrize(
|
|
81
|
+
("method_name", "args", "operation", "expected_payload"),
|
|
82
|
+
[
|
|
83
|
+
(
|
|
84
|
+
"send_dm",
|
|
85
|
+
("Review-Core", "hello"),
|
|
86
|
+
"send_dm",
|
|
87
|
+
{"to": "Review-Core", "text": "hello", "from": "TransportTester"},
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
"post_message",
|
|
91
|
+
("core-py", "status update"),
|
|
92
|
+
"post_message",
|
|
93
|
+
{"channel": "core-py", "text": "status update", "from": "TransportTester"},
|
|
94
|
+
),
|
|
95
|
+
(
|
|
96
|
+
"reply",
|
|
97
|
+
("message-123", "thread reply"),
|
|
98
|
+
"reply",
|
|
99
|
+
{"message_id": "message-123", "text": "thread reply", "from": "TransportTester"},
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
async def test_send_methods_use_expected_http_payload(
|
|
104
|
+
relay_server,
|
|
105
|
+
method_name,
|
|
106
|
+
args,
|
|
107
|
+
operation,
|
|
108
|
+
expected_payload,
|
|
109
|
+
):
|
|
114
110
|
RelayTransport = _transport_class()
|
|
115
111
|
transport = RelayTransport("TransportTester", relay_server.make_config())
|
|
116
112
|
await transport.connect()
|
|
117
113
|
|
|
118
114
|
try:
|
|
119
|
-
message_id = await transport
|
|
115
|
+
message_id = await getattr(transport, method_name)(*args)
|
|
120
116
|
finally:
|
|
121
117
|
await transport.disconnect()
|
|
122
118
|
|
|
123
119
|
assert message_id.startswith("message-")
|
|
124
|
-
|
|
125
|
-
assert reply_req["json"]["text"] == "thread reply"
|
|
126
|
-
assert "/v1/messages/message-123/replies" in reply_req["path"]
|
|
120
|
+
assert relay_server.requests[operation][-1]["json"] == expected_payload
|
|
127
121
|
|
|
128
122
|
|
|
129
123
|
@pytest.mark.asyncio
|
|
@@ -148,10 +142,16 @@ async def test_check_inbox_returns_message_objects_and_drains_server_inbox(relay
|
|
|
148
142
|
finally:
|
|
149
143
|
await transport.disconnect()
|
|
150
144
|
|
|
151
|
-
assert
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
145
|
+
assert messages == [
|
|
146
|
+
Message(
|
|
147
|
+
sender=queued["sender"],
|
|
148
|
+
text=queued["text"],
|
|
149
|
+
channel=queued["channel"],
|
|
150
|
+
thread_id=queued["thread_id"],
|
|
151
|
+
timestamp=queued["timestamp"],
|
|
152
|
+
message_id=queued["message_id"],
|
|
153
|
+
)
|
|
154
|
+
]
|
|
155
155
|
assert empty == []
|
|
156
156
|
|
|
157
157
|
|
|
@@ -197,11 +197,16 @@ async def test_websocket_messages_are_decoded_and_delivered_to_callback(relay_se
|
|
|
197
197
|
finally:
|
|
198
198
|
await transport.disconnect()
|
|
199
199
|
|
|
200
|
-
assert
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
200
|
+
assert received == [
|
|
201
|
+
Message(
|
|
202
|
+
sender="Review-Core",
|
|
203
|
+
text="looks good",
|
|
204
|
+
channel="core-py",
|
|
205
|
+
thread_id=None,
|
|
206
|
+
timestamp=None,
|
|
207
|
+
message_id="message-ws-1",
|
|
208
|
+
)
|
|
209
|
+
]
|
|
205
210
|
|
|
206
211
|
|
|
207
212
|
@pytest.mark.asyncio
|
|
@@ -242,8 +247,14 @@ async def test_transport_reconnects_after_websocket_disconnect(relay_server, mon
|
|
|
242
247
|
finally:
|
|
243
248
|
await transport.disconnect()
|
|
244
249
|
|
|
245
|
-
assert received[-1]
|
|
246
|
-
|
|
250
|
+
assert received[-1] == Message(
|
|
251
|
+
sender="Impl-Core",
|
|
252
|
+
text="reconnected",
|
|
253
|
+
channel=None,
|
|
254
|
+
thread_id=None,
|
|
255
|
+
timestamp=None,
|
|
256
|
+
message_id="message-reconnect-1",
|
|
257
|
+
)
|
|
247
258
|
assert [delay for delay in sleep_calls if delay >= 1][:1] == [1]
|
|
248
259
|
|
|
249
260
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from agent_relay.client import AgentRelayClient
|
|
8
|
+
from agent_relay.relay import AgentRelay, HumanHandle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_client_send_message_includes_mode_in_payload():
|
|
13
|
+
client = AgentRelayClient(binary_path="agent-relay-broker")
|
|
14
|
+
client.start_client = AsyncMock()
|
|
15
|
+
|
|
16
|
+
payloads: list[dict] = []
|
|
17
|
+
|
|
18
|
+
async def fake_request_ok(type_: str, payload: dict):
|
|
19
|
+
assert type_ == "send_message"
|
|
20
|
+
payloads.append(payload)
|
|
21
|
+
return {"event_id": "evt-1", "targets": ["Worker"]}
|
|
22
|
+
|
|
23
|
+
client._request_ok = fake_request_ok # type: ignore[method-assign]
|
|
24
|
+
|
|
25
|
+
result = await client.send_message(
|
|
26
|
+
to="Worker",
|
|
27
|
+
text="hello",
|
|
28
|
+
from_="system",
|
|
29
|
+
thread_id="thread-1",
|
|
30
|
+
priority=5,
|
|
31
|
+
data={"k": "v"},
|
|
32
|
+
mode="steer",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert result["event_id"] == "evt-1"
|
|
36
|
+
assert payloads == [
|
|
37
|
+
{
|
|
38
|
+
"to": "Worker",
|
|
39
|
+
"text": "hello",
|
|
40
|
+
"from": "system",
|
|
41
|
+
"thread_id": "thread-1",
|
|
42
|
+
"priority": 5,
|
|
43
|
+
"data": {"k": "v"},
|
|
44
|
+
"mode": "steer",
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_human_send_message_passes_mode_and_sets_message_mode():
|
|
51
|
+
relay = AgentRelay()
|
|
52
|
+
client = AsyncMock()
|
|
53
|
+
client.send_message = AsyncMock(return_value={"event_id": "evt-2"})
|
|
54
|
+
relay._ensure_started = AsyncMock(return_value=client)
|
|
55
|
+
|
|
56
|
+
human = HumanHandle("system", relay)
|
|
57
|
+
msg = await human.send_message(to="Worker", text="status?", mode="wait")
|
|
58
|
+
|
|
59
|
+
assert msg.mode == "wait"
|
|
60
|
+
client.send_message.assert_awaited_once_with(
|
|
61
|
+
to="Worker",
|
|
62
|
+
text="status?",
|
|
63
|
+
from_="system",
|
|
64
|
+
thread_id=None,
|
|
65
|
+
priority=None,
|
|
66
|
+
data=None,
|
|
67
|
+
mode="wait",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_agent_send_message_passes_mode_and_sets_message_mode():
|
|
73
|
+
relay = AgentRelay()
|
|
74
|
+
client = AsyncMock()
|
|
75
|
+
client.spawn_pty = AsyncMock(return_value={"name": "Worker", "runtime": "pty"})
|
|
76
|
+
client.send_message = AsyncMock(return_value={"event_id": "evt-3"})
|
|
77
|
+
relay._ensure_started = AsyncMock(return_value=client)
|
|
78
|
+
|
|
79
|
+
agent = await relay.spawn("Worker", "claude")
|
|
80
|
+
msg = await agent.send_message(to="Reviewer", text="ready", mode="steer")
|
|
81
|
+
|
|
82
|
+
assert msg.mode == "steer"
|
|
83
|
+
client.send_message.assert_awaited_with(
|
|
84
|
+
to="Reviewer",
|
|
85
|
+
text="ready",
|
|
86
|
+
from_="Worker",
|
|
87
|
+
thread_id=None,
|
|
88
|
+
priority=None,
|
|
89
|
+
data=None,
|
|
90
|
+
mode="steer",
|
|
91
|
+
)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Delegate
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
public protocol RelayObserverDelegate: AnyObject {
|
|
7
|
+
func relayObserver(_ observer: RelayObserver, didReceiveEvent event: RelayObserverEvent)
|
|
8
|
+
func relayObserverDidConnect(_ observer: RelayObserver)
|
|
9
|
+
func relayObserverDidDisconnect(_ observer: RelayObserver, error: Error?)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// MARK: - RelayObserver
|
|
13
|
+
|
|
14
|
+
public final class RelayObserver: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable {
|
|
15
|
+
|
|
16
|
+
// MARK: - ConnectionState
|
|
17
|
+
|
|
18
|
+
public enum ConnectionState: Equatable, Sendable {
|
|
19
|
+
case disconnected
|
|
20
|
+
case connecting
|
|
21
|
+
case connected
|
|
22
|
+
case reconnecting(attempt: Int)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// MARK: - Public Properties (thread-safe via queue)
|
|
26
|
+
|
|
27
|
+
public var connectionState: ConnectionState {
|
|
28
|
+
queue.sync { _connectionState }
|
|
29
|
+
}
|
|
30
|
+
public var lastEvent: RelayObserverEvent? {
|
|
31
|
+
queue.sync { _lastEvent }
|
|
32
|
+
}
|
|
33
|
+
public var eventCounter: Int {
|
|
34
|
+
queue.sync { _eventCounter }
|
|
35
|
+
}
|
|
36
|
+
public weak var delegate: RelayObserverDelegate?
|
|
37
|
+
|
|
38
|
+
// MARK: - AsyncStream
|
|
39
|
+
|
|
40
|
+
public var events: AsyncStream<RelayObserverEvent> {
|
|
41
|
+
queue.sync {
|
|
42
|
+
_eventsContinuation?.finish()
|
|
43
|
+
return AsyncStream { continuation in
|
|
44
|
+
self._eventsContinuation = continuation
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Private Properties
|
|
50
|
+
|
|
51
|
+
private let queue = DispatchQueue(label: "com.agentrelay.observer", qos: .userInitiated)
|
|
52
|
+
private var _connectionState: ConnectionState = .disconnected
|
|
53
|
+
private var _lastEvent: RelayObserverEvent?
|
|
54
|
+
private var _eventCounter: Int = 0
|
|
55
|
+
private var webSocketTask: URLSessionWebSocketTask?
|
|
56
|
+
private var urlSession: URLSession?
|
|
57
|
+
private let maxReconnectAttempts: Int
|
|
58
|
+
private let baseReconnectDelay: TimeInterval
|
|
59
|
+
private var reconnectAttempts: Int = 0
|
|
60
|
+
private var reconnectTask: Task<Void, Never>?
|
|
61
|
+
private var subscribedChannel: String?
|
|
62
|
+
private var pendingOutbound: [String] = []
|
|
63
|
+
private var isConnectionReady: Bool = false
|
|
64
|
+
private var activeURL: URL?
|
|
65
|
+
private var _eventsContinuation: AsyncStream<RelayObserverEvent>.Continuation?
|
|
66
|
+
|
|
67
|
+
private let jsonEncoder: JSONEncoder = {
|
|
68
|
+
let encoder = JSONEncoder()
|
|
69
|
+
encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
70
|
+
return encoder
|
|
71
|
+
}()
|
|
72
|
+
|
|
73
|
+
private let jsonDecoder: JSONDecoder = {
|
|
74
|
+
// NOT using convertFromSnakeCase — we use explicit CodingKeys
|
|
75
|
+
// because of dual-name fields (name/agent_name, step/step_name)
|
|
76
|
+
let decoder = JSONDecoder()
|
|
77
|
+
return decoder
|
|
78
|
+
}()
|
|
79
|
+
|
|
80
|
+
// MARK: - Init
|
|
81
|
+
|
|
82
|
+
public init(maxReconnectAttempts: Int = 8, baseReconnectDelay: TimeInterval = 1.0) {
|
|
83
|
+
self.maxReconnectAttempts = maxReconnectAttempts
|
|
84
|
+
self.baseReconnectDelay = baseReconnectDelay
|
|
85
|
+
super.init()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// MARK: - Public Methods
|
|
89
|
+
|
|
90
|
+
/// Connect to a WebSocket proxy URL and subscribe to a channel on open.
|
|
91
|
+
public func connect(url: URL, channel: String) {
|
|
92
|
+
queue.sync {
|
|
93
|
+
self.subscribedChannel = channel
|
|
94
|
+
self._openSocket(url: url)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Connect to a WebSocket proxy URL without channel subscription.
|
|
99
|
+
public func connect(url: URL) {
|
|
100
|
+
queue.sync {
|
|
101
|
+
self.subscribedChannel = nil
|
|
102
|
+
self._openSocket(url: url)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Disconnect — closes socket, cancels reconnect, clears state.
|
|
107
|
+
public func disconnect() {
|
|
108
|
+
queue.sync {
|
|
109
|
+
reconnectTask?.cancel()
|
|
110
|
+
reconnectTask = nil
|
|
111
|
+
reconnectAttempts = 0
|
|
112
|
+
isConnectionReady = false
|
|
113
|
+
subscribedChannel = nil
|
|
114
|
+
pendingOutbound.removeAll()
|
|
115
|
+
activeURL = nil
|
|
116
|
+
_closeSocket(code: .goingAway, reason: nil)
|
|
117
|
+
_connectionState = .disconnected
|
|
118
|
+
_eventsContinuation?.finish()
|
|
119
|
+
_eventsContinuation = nil
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Send a message to a channel through the proxy.
|
|
124
|
+
public func sendChannel(
|
|
125
|
+
channel: String,
|
|
126
|
+
text: String,
|
|
127
|
+
personas: [String]? = nil,
|
|
128
|
+
cliPreferences: [String: String]? = nil
|
|
129
|
+
) throws {
|
|
130
|
+
let msg = ObserverChannelSendMessage(
|
|
131
|
+
channel: channel,
|
|
132
|
+
text: text,
|
|
133
|
+
personas: personas,
|
|
134
|
+
cliPreferences: cliPreferences
|
|
135
|
+
)
|
|
136
|
+
try queue.sync {
|
|
137
|
+
try _sendEncodable(msg)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Send a direct message to a specific agent through the proxy.
|
|
142
|
+
public func sendDirect(to: String, text: String) throws {
|
|
143
|
+
let msg = ObserverDirectSendMessage(to: to, text: text)
|
|
144
|
+
try queue.sync {
|
|
145
|
+
try _sendEncodable(msg)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// MARK: - Private Methods (must be called on queue)
|
|
150
|
+
|
|
151
|
+
private func _openSocket(url: URL) {
|
|
152
|
+
_closeSocket(code: .goingAway, reason: nil)
|
|
153
|
+
activeURL = url
|
|
154
|
+
_connectionState = .connecting
|
|
155
|
+
isConnectionReady = false
|
|
156
|
+
|
|
157
|
+
let delegateQueue = OperationQueue()
|
|
158
|
+
delegateQueue.underlyingQueue = queue
|
|
159
|
+
delegateQueue.maxConcurrentOperationCount = 1
|
|
160
|
+
|
|
161
|
+
let session = URLSession(
|
|
162
|
+
configuration: .default,
|
|
163
|
+
delegate: self,
|
|
164
|
+
delegateQueue: delegateQueue
|
|
165
|
+
)
|
|
166
|
+
self.urlSession = session
|
|
167
|
+
let task = session.webSocketTask(with: url)
|
|
168
|
+
self.webSocketTask = task
|
|
169
|
+
task.resume()
|
|
170
|
+
_scheduleReceive()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private func _closeSocket(code: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
|
174
|
+
webSocketTask?.cancel(with: code, reason: reason)
|
|
175
|
+
webSocketTask = nil
|
|
176
|
+
urlSession?.invalidateAndCancel()
|
|
177
|
+
urlSession = nil
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func _scheduleReceive() {
|
|
181
|
+
webSocketTask?.receive { [weak self] result in
|
|
182
|
+
guard let self else { return }
|
|
183
|
+
// Callback arrives on our queue via delegateQueue
|
|
184
|
+
switch result {
|
|
185
|
+
case .success(let message):
|
|
186
|
+
self._handleMessage(message)
|
|
187
|
+
self._scheduleReceive()
|
|
188
|
+
case .failure(let error):
|
|
189
|
+
self._handleSocketError(error)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private func _handleMessage(_ message: URLSessionWebSocketTask.Message) {
|
|
195
|
+
let data: Data
|
|
196
|
+
switch message {
|
|
197
|
+
case .string(let text):
|
|
198
|
+
guard let d = text.data(using: .utf8) else { return }
|
|
199
|
+
data = d
|
|
200
|
+
case .data(let d):
|
|
201
|
+
data = d
|
|
202
|
+
@unknown default:
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
guard let event = try? jsonDecoder.decode(RelayObserverEvent.self, from: data) else { return }
|
|
207
|
+
|
|
208
|
+
_lastEvent = event
|
|
209
|
+
_eventCounter += 1
|
|
210
|
+
|
|
211
|
+
if let delegate {
|
|
212
|
+
Task { @MainActor in
|
|
213
|
+
delegate.relayObserver(self, didReceiveEvent: event)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_eventsContinuation?.yield(event)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func _handleSocketError(_ error: Error) {
|
|
221
|
+
guard reconnectAttempts < maxReconnectAttempts else {
|
|
222
|
+
_connectionState = .disconnected
|
|
223
|
+
let delegate = self.delegate
|
|
224
|
+
Task { @MainActor in
|
|
225
|
+
delegate?.relayObserverDidDisconnect(self, error: error)
|
|
226
|
+
}
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
reconnectAttempts += 1
|
|
231
|
+
_connectionState = .reconnecting(attempt: reconnectAttempts)
|
|
232
|
+
|
|
233
|
+
let delay = baseReconnectDelay * pow(2.0, Double(reconnectAttempts - 1))
|
|
234
|
+
|
|
235
|
+
reconnectTask?.cancel()
|
|
236
|
+
reconnectTask = Task { [weak self] in
|
|
237
|
+
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
238
|
+
guard !Task.isCancelled, let self else { return }
|
|
239
|
+
self.queue.sync {
|
|
240
|
+
guard let url = self.activeURL else { return }
|
|
241
|
+
self._openSocket(url: url)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private func _sendSubscription() {
|
|
247
|
+
guard let channel = subscribedChannel else { return }
|
|
248
|
+
let msg = ObserverSubscribeMessage(channel: channel)
|
|
249
|
+
if let data = try? jsonEncoder.encode(msg),
|
|
250
|
+
let str = String(data: data, encoding: .utf8) {
|
|
251
|
+
webSocketTask?.send(.string(str)) { _ in }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private func _sendEncodable<T: Encodable>(_ value: T) throws {
|
|
256
|
+
guard let task = webSocketTask else {
|
|
257
|
+
throw RelayObserverError.notConnected
|
|
258
|
+
}
|
|
259
|
+
guard let data = try? jsonEncoder.encode(value),
|
|
260
|
+
let str = String(data: data, encoding: .utf8) else {
|
|
261
|
+
throw RelayObserverError.encodingFailed
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if isConnectionReady {
|
|
265
|
+
task.send(.string(str)) { _ in }
|
|
266
|
+
} else {
|
|
267
|
+
pendingOutbound.append(str)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private func _flushPendingOutbound() {
|
|
272
|
+
guard let task = webSocketTask else { return }
|
|
273
|
+
for str in pendingOutbound {
|
|
274
|
+
task.send(.string(str)) { _ in }
|
|
275
|
+
}
|
|
276
|
+
pendingOutbound.removeAll()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// MARK: - URLSessionWebSocketDelegate (callbacks arrive on queue via delegateQueue)
|
|
280
|
+
|
|
281
|
+
public func urlSession(
|
|
282
|
+
_ session: URLSession,
|
|
283
|
+
webSocketTask: URLSessionWebSocketTask,
|
|
284
|
+
didOpenWithProtocol protocol: String?
|
|
285
|
+
) {
|
|
286
|
+
_connectionState = .connected
|
|
287
|
+
reconnectAttempts = 0
|
|
288
|
+
isConnectionReady = true
|
|
289
|
+
|
|
290
|
+
_flushPendingOutbound()
|
|
291
|
+
_sendSubscription()
|
|
292
|
+
|
|
293
|
+
let delegate = self.delegate
|
|
294
|
+
Task { @MainActor in
|
|
295
|
+
delegate?.relayObserverDidConnect(self)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
public func urlSession(
|
|
300
|
+
_ session: URLSession,
|
|
301
|
+
webSocketTask: URLSessionWebSocketTask,
|
|
302
|
+
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
|
303
|
+
reason: Data?
|
|
304
|
+
) {
|
|
305
|
+
isConnectionReady = false
|
|
306
|
+
|
|
307
|
+
if closeCode != .goingAway && closeCode != .normalClosure {
|
|
308
|
+
_handleSocketError(
|
|
309
|
+
NSError(
|
|
310
|
+
domain: "RelayObserver",
|
|
311
|
+
code: Int(closeCode.rawValue),
|
|
312
|
+
userInfo: [NSLocalizedDescriptionKey: "WebSocket closed with code \(closeCode.rawValue)"]
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
} else {
|
|
316
|
+
_connectionState = .disconnected
|
|
317
|
+
let delegate = self.delegate
|
|
318
|
+
Task { @MainActor in
|
|
319
|
+
delegate?.relayObserverDidDisconnect(self, error: nil)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|