agent-relay 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -244
- 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 +342 -60
- package/dist/src/cli/commands/core.d.ts +2 -0
- package/dist/src/cli/commands/core.d.ts.map +1 -1
- package/dist/src/cli/commands/core.js +9 -2
- package/dist/src/cli/commands/core.js.map +1 -1
- package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
- package/dist/src/cli/lib/broker-lifecycle.js +87 -28
- package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
- package/package.json +9 -8
- package/packages/acp-bridge/README.md +50 -67
- 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/policy/package.json +2 -2
- package/packages/sdk/README.md +169 -64
- package/packages/sdk/dist/__tests__/contract-fixtures.test.js +76 -9
- package/packages/sdk/dist/__tests__/contract-fixtures.test.js.map +1 -1
- package/packages/sdk/dist/__tests__/facade.test.js +48 -0
- package/packages/sdk/dist/__tests__/facade.test.js.map +1 -1
- package/packages/sdk/dist/__tests__/integration.test.js +11 -5
- package/packages/sdk/dist/__tests__/integration.test.js.map +1 -1
- package/packages/sdk/dist/__tests__/unit.test.js +36 -0
- package/packages/sdk/dist/__tests__/unit.test.js.map +1 -1
- package/packages/sdk/dist/client.d.ts +36 -3
- package/packages/sdk/dist/client.d.ts.map +1 -1
- package/packages/sdk/dist/client.js +142 -9
- package/packages/sdk/dist/client.js.map +1 -1
- package/packages/sdk/dist/protocol.d.ts +7 -1
- package/packages/sdk/dist/protocol.d.ts.map +1 -1
- package/packages/sdk/dist/relay.d.ts +74 -11
- package/packages/sdk/dist/relay.d.ts.map +1 -1
- package/packages/sdk/dist/relay.js +175 -27
- package/packages/sdk/dist/relay.js.map +1 -1
- package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/runner.js +71 -36
- package/packages/sdk/dist/workflows/runner.js.map +1 -1
- package/packages/sdk/dist/workflows/types.d.ts +1 -1
- package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/sdk/src/__tests__/contract-fixtures.test.ts +88 -9
- package/packages/sdk/src/__tests__/error-scenarios.test.ts +1 -1
- package/packages/sdk/src/__tests__/facade.test.ts +68 -0
- package/packages/sdk/src/__tests__/idle-nudge.test.ts +205 -257
- package/packages/sdk/src/__tests__/integration.test.ts +11 -5
- package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +277 -13
- package/packages/sdk/src/__tests__/swarm-coordinator.test.ts +1 -0
- package/packages/sdk/src/__tests__/unit.test.ts +44 -0
- package/packages/sdk/src/__tests__/workflow-runner.test.ts +67 -7
- package/packages/sdk/src/__tests__/workflow-trajectory.test.ts +4 -5
- package/packages/sdk/src/client.ts +195 -14
- package/packages/sdk/src/examples/workflows/runner-idle-refactor.yaml +306 -0
- package/packages/sdk/src/protocol.ts +7 -2
- package/packages/sdk/src/relay.ts +271 -38
- package/packages/sdk/src/workflows/runner.ts +73 -42
- package/packages/sdk/src/workflows/schema.json +1 -1
- package/packages/sdk/src/workflows/types.ts +1 -1
- package/packages/sdk/vitest.config.ts +1 -0
- package/packages/sdk-py/README.md +89 -102
- package/packages/sdk-py/agent_relay/__init__.py +16 -19
- package/packages/sdk-py/pyproject.toml +6 -2
- package/packages/sdk-py/src/agent_relay/__init__.py +35 -1
- package/packages/sdk-py/src/agent_relay/client.py +776 -0
- package/packages/sdk-py/src/agent_relay/models.py +27 -0
- package/packages/sdk-py/src/agent_relay/protocol.py +114 -0
- package/packages/sdk-py/src/agent_relay/relay.py +860 -0
- package/packages/sdk-py/tests/test_relay_lifecycle_hooks.py +250 -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
- package/scripts/postinstall.js +35 -162
- package/packages/sdk/.trajectories/active/traj_1771875803391_84ca57b2.json +0 -50
- package/packages/sdk/.trajectories/active/traj_1771891934534_06504121.json +0 -50
- package/packages/sdk/.trajectories/active/traj_1771891957929_211afc4e.json +0 -50
- package/packages/sdk/.trajectories/active/traj_1771891982509_38c84638.json +0 -50
- package/packages/sdk/.trajectories/completed/traj_1771875803188_cd6d181c.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803204_f2aeb8c8.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803210_d65f3f1a.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803218_e454a25d.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803223_d7a64815.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803227_7e56da5b.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803235_4fbf93b4.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803243_47931c71.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803258_3816f3fe.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803268_8061140e.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875803326_ae6f9c78.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771875808396_cbde0a6c.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771875812026_aa2442bb.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771875815431_c2c656c5.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771875818645_3a4dbf02.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891934403_24923c03.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934421_dca16e24.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934430_057706f7.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934442_faf97382.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934454_5542ecd5.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934464_12202a08.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934487_94378275.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934503_ca728c13.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934519_100af69a.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934536_62ad39d9.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891934553_d6798a52.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891939537_541c8096.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891942985_36ab9a4d.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891946453_e8a6e05f.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891949838_5de0de84.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891957807_0ecfb4f4.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957827_c4539239.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957836_91168b48.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957848_8c5cad0b.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957857_0986b293.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957872_8a3113af.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957884_0bb85208.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957892_86c75e2e.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957907_98ca0e6f.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957918_d9091231.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891957931_dcaf77ed.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891962931_eb1fdee2.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891966262_9061a93f.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891969915_1adaba19.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891973588_f08b79e9.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891982421_f1985bce.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982432_e7a84163.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982447_369b842a.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982469_5fc45199.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982495_454c7cb3.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982514_08098e03.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982526_b351d778.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982533_fa542d83.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982540_18ab24dc.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982544_5b4fa163.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891982548_c13f089a.json +0 -80
- package/packages/sdk/.trajectories/completed/traj_1771891987510_23f6da1f.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891991466_912c2e04.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891994891_60604be2.json +0 -91
- package/packages/sdk/.trajectories/completed/traj_1771891998370_cfaf9b8b.json +0 -91
- package/packages/sdk/bin/agent-relay-broker +0 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
"""High-level facade for the Agent Relay SDK.
|
|
2
|
+
|
|
3
|
+
Provides a clean, property-based API on top of the lower-level
|
|
4
|
+
AgentRelayClient protocol client.
|
|
5
|
+
|
|
6
|
+
Mirrors packages/sdk/src/relay.ts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from .client import AgentRelayClient
|
|
19
|
+
from .protocol import AgentRuntime, BrokerEvent
|
|
20
|
+
|
|
21
|
+
# ── Public types ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
AgentStatus = str # "spawning" | "ready" | "idle" | "exited"
|
|
24
|
+
|
|
25
|
+
EventHook = Optional[Callable[..., None]]
|
|
26
|
+
LifecycleHook = Optional[Callable[[dict[str, Any]], None | Awaitable[None]]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Message:
|
|
31
|
+
"""A relay message between agents."""
|
|
32
|
+
|
|
33
|
+
event_id: str
|
|
34
|
+
from_name: str
|
|
35
|
+
to: str
|
|
36
|
+
text: str
|
|
37
|
+
thread_id: Optional[str] = None
|
|
38
|
+
data: Optional[dict[str, Any]] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SpawnOptions:
|
|
43
|
+
"""Options for spawning an agent."""
|
|
44
|
+
|
|
45
|
+
args: list[str] = field(default_factory=list)
|
|
46
|
+
channels: list[str] = field(default_factory=list)
|
|
47
|
+
model: Optional[str] = None
|
|
48
|
+
cwd: Optional[str] = None
|
|
49
|
+
team: Optional[str] = None
|
|
50
|
+
shadow_of: Optional[str] = None
|
|
51
|
+
shadow_mode: Optional[str] = None
|
|
52
|
+
idle_threshold_secs: Optional[int] = None
|
|
53
|
+
restart_policy: Optional[dict[str, Any]] = None
|
|
54
|
+
on_start: LifecycleHook = None
|
|
55
|
+
on_success: LifecycleHook = None
|
|
56
|
+
on_error: LifecycleHook = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Agent handle ──────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Agent:
|
|
63
|
+
"""Handle for a spawned agent with lifecycle methods."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
name: str,
|
|
68
|
+
runtime: AgentRuntime,
|
|
69
|
+
channels: list[str],
|
|
70
|
+
relay: AgentRelay,
|
|
71
|
+
):
|
|
72
|
+
self._name = name
|
|
73
|
+
self._runtime = runtime
|
|
74
|
+
self._channels = channels
|
|
75
|
+
self._relay = relay
|
|
76
|
+
self.exit_code: Optional[int] = None
|
|
77
|
+
self.exit_signal: Optional[str] = None
|
|
78
|
+
self.exit_reason: Optional[str] = None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def name(self) -> str:
|
|
82
|
+
return self._name
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def runtime(self) -> AgentRuntime:
|
|
86
|
+
return self._runtime
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def channels(self) -> list[str]:
|
|
90
|
+
return self._channels
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def status(self) -> AgentStatus:
|
|
94
|
+
if self._name in self._relay._exited_agents:
|
|
95
|
+
return "exited"
|
|
96
|
+
if self._name in self._relay._idle_agents:
|
|
97
|
+
return "idle"
|
|
98
|
+
if self._name in self._relay._ready_agents:
|
|
99
|
+
return "ready"
|
|
100
|
+
return "spawning"
|
|
101
|
+
|
|
102
|
+
async def release(
|
|
103
|
+
self,
|
|
104
|
+
reason: Optional[str] = None,
|
|
105
|
+
*,
|
|
106
|
+
on_start: LifecycleHook = None,
|
|
107
|
+
on_success: LifecycleHook = None,
|
|
108
|
+
on_error: LifecycleHook = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
context = {
|
|
111
|
+
"name": self._name,
|
|
112
|
+
"reason": reason,
|
|
113
|
+
}
|
|
114
|
+
client = await self._relay._ensure_started()
|
|
115
|
+
await self._relay._invoke_lifecycle_hook(
|
|
116
|
+
on_start,
|
|
117
|
+
context,
|
|
118
|
+
f'release("{self._name}") on_start',
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
await client.release(self._name, reason)
|
|
122
|
+
await self._relay._invoke_lifecycle_hook(
|
|
123
|
+
on_success,
|
|
124
|
+
context,
|
|
125
|
+
f'release("{self._name}") on_success',
|
|
126
|
+
)
|
|
127
|
+
except Exception as error:
|
|
128
|
+
await self._relay._invoke_lifecycle_hook(
|
|
129
|
+
on_error,
|
|
130
|
+
{
|
|
131
|
+
**context,
|
|
132
|
+
"error": error,
|
|
133
|
+
},
|
|
134
|
+
f'release("{self._name}") on_error',
|
|
135
|
+
)
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
async def wait_for_ready(self, timeout_ms: int = 60_000) -> None:
|
|
139
|
+
await self._relay.wait_for_agent_ready(self._name, timeout_ms)
|
|
140
|
+
|
|
141
|
+
async def wait_for_exit(self, timeout_ms: Optional[int] = None) -> str:
|
|
142
|
+
"""Wait for agent to exit. Returns 'exited', 'released', or 'timeout'."""
|
|
143
|
+
if self._name not in self._relay._known_agents:
|
|
144
|
+
return "exited"
|
|
145
|
+
if timeout_ms == 0:
|
|
146
|
+
return "timeout"
|
|
147
|
+
|
|
148
|
+
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
149
|
+
self._relay._exit_resolvers.setdefault(self._name, []).append(future)
|
|
150
|
+
|
|
151
|
+
if timeout_ms is not None:
|
|
152
|
+
try:
|
|
153
|
+
return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
|
|
154
|
+
except asyncio.TimeoutError:
|
|
155
|
+
futures = self._relay._exit_resolvers.get(self._name, [])
|
|
156
|
+
try:
|
|
157
|
+
futures.remove(future)
|
|
158
|
+
except ValueError:
|
|
159
|
+
pass
|
|
160
|
+
if not futures:
|
|
161
|
+
self._relay._exit_resolvers.pop(self._name, None)
|
|
162
|
+
return "timeout"
|
|
163
|
+
else:
|
|
164
|
+
return await future
|
|
165
|
+
|
|
166
|
+
async def wait_for_idle(self, timeout_ms: Optional[int] = None) -> str:
|
|
167
|
+
"""Wait for agent to go idle. Returns 'idle', 'exited', or 'timeout'."""
|
|
168
|
+
if self._name not in self._relay._known_agents:
|
|
169
|
+
return "exited"
|
|
170
|
+
if timeout_ms == 0:
|
|
171
|
+
return "timeout"
|
|
172
|
+
|
|
173
|
+
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
174
|
+
self._relay._idle_resolvers.setdefault(self._name, []).append(future)
|
|
175
|
+
|
|
176
|
+
if timeout_ms is not None:
|
|
177
|
+
try:
|
|
178
|
+
return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
|
|
179
|
+
except asyncio.TimeoutError:
|
|
180
|
+
futures = self._relay._idle_resolvers.get(self._name, [])
|
|
181
|
+
try:
|
|
182
|
+
futures.remove(future)
|
|
183
|
+
except ValueError:
|
|
184
|
+
pass
|
|
185
|
+
if not futures:
|
|
186
|
+
self._relay._idle_resolvers.pop(self._name, None)
|
|
187
|
+
return "timeout"
|
|
188
|
+
else:
|
|
189
|
+
return await future
|
|
190
|
+
|
|
191
|
+
async def send_message(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
to: str,
|
|
195
|
+
text: str,
|
|
196
|
+
thread_id: Optional[str] = None,
|
|
197
|
+
priority: Optional[int] = None,
|
|
198
|
+
data: Optional[dict[str, Any]] = None,
|
|
199
|
+
) -> Message:
|
|
200
|
+
client = await self._relay._ensure_started()
|
|
201
|
+
result = await client.send_message(
|
|
202
|
+
to=to,
|
|
203
|
+
text=text,
|
|
204
|
+
from_=self._name,
|
|
205
|
+
thread_id=thread_id,
|
|
206
|
+
priority=priority,
|
|
207
|
+
data=data,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
event_id = result.get("event_id", secrets.token_hex(8))
|
|
211
|
+
msg = Message(
|
|
212
|
+
event_id=event_id,
|
|
213
|
+
from_name=self._name,
|
|
214
|
+
to=to,
|
|
215
|
+
text=text,
|
|
216
|
+
thread_id=thread_id,
|
|
217
|
+
data=data,
|
|
218
|
+
)
|
|
219
|
+
# Don't fire hook for unsupported operations
|
|
220
|
+
if event_id != "unsupported_operation" and self._relay.on_message_sent:
|
|
221
|
+
self._relay.on_message_sent(msg)
|
|
222
|
+
return msg
|
|
223
|
+
|
|
224
|
+
def on_output(self, callback: Callable[[str], None]) -> Callable[[], None]:
|
|
225
|
+
listeners = self._relay._output_listeners.setdefault(self._name, [])
|
|
226
|
+
listeners.append(callback)
|
|
227
|
+
|
|
228
|
+
def unsubscribe() -> None:
|
|
229
|
+
try:
|
|
230
|
+
listeners.remove(callback)
|
|
231
|
+
except ValueError:
|
|
232
|
+
pass
|
|
233
|
+
if not listeners:
|
|
234
|
+
self._relay._output_listeners.pop(self._name, None)
|
|
235
|
+
|
|
236
|
+
return unsubscribe
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── Human handle ──────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class HumanHandle:
|
|
243
|
+
"""A messaging handle for human/system messages."""
|
|
244
|
+
|
|
245
|
+
def __init__(self, name: str, relay: AgentRelay):
|
|
246
|
+
self._name = name
|
|
247
|
+
self._relay = relay
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def name(self) -> str:
|
|
251
|
+
return self._name
|
|
252
|
+
|
|
253
|
+
async def send_message(
|
|
254
|
+
self,
|
|
255
|
+
*,
|
|
256
|
+
to: str,
|
|
257
|
+
text: str,
|
|
258
|
+
thread_id: Optional[str] = None,
|
|
259
|
+
priority: Optional[int] = None,
|
|
260
|
+
data: Optional[dict[str, Any]] = None,
|
|
261
|
+
) -> Message:
|
|
262
|
+
client = await self._relay._ensure_started()
|
|
263
|
+
result = await client.send_message(
|
|
264
|
+
to=to,
|
|
265
|
+
text=text,
|
|
266
|
+
from_=self._name,
|
|
267
|
+
thread_id=thread_id,
|
|
268
|
+
priority=priority,
|
|
269
|
+
data=data,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
event_id = result.get("event_id", secrets.token_hex(8))
|
|
273
|
+
msg = Message(
|
|
274
|
+
event_id=event_id,
|
|
275
|
+
from_name=self._name,
|
|
276
|
+
to=to,
|
|
277
|
+
text=text,
|
|
278
|
+
thread_id=thread_id,
|
|
279
|
+
data=data,
|
|
280
|
+
)
|
|
281
|
+
# Don't fire hook for unsupported operations
|
|
282
|
+
if event_id != "unsupported_operation" and self._relay.on_message_sent:
|
|
283
|
+
self._relay.on_message_sent(msg)
|
|
284
|
+
return msg
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── Agent spawner ─────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class AgentSpawner:
|
|
291
|
+
"""Shorthand spawner for a specific CLI (e.g., relay.claude.spawn(...))."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, cli: str, default_name: str, relay: AgentRelay):
|
|
294
|
+
self._cli = cli
|
|
295
|
+
self._default_name = default_name
|
|
296
|
+
self._relay = relay
|
|
297
|
+
|
|
298
|
+
async def spawn(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
name: Optional[str] = None,
|
|
302
|
+
args: Optional[list[str]] = None,
|
|
303
|
+
channels: Optional[list[str]] = None,
|
|
304
|
+
task: Optional[str] = None,
|
|
305
|
+
model: Optional[str] = None,
|
|
306
|
+
cwd: Optional[str] = None,
|
|
307
|
+
on_start: LifecycleHook = None,
|
|
308
|
+
on_success: LifecycleHook = None,
|
|
309
|
+
on_error: LifecycleHook = None,
|
|
310
|
+
) -> Agent:
|
|
311
|
+
agent_name = name or self._default_name
|
|
312
|
+
agent_channels = channels or ["general"]
|
|
313
|
+
context = {
|
|
314
|
+
"name": agent_name,
|
|
315
|
+
"cli": self._cli,
|
|
316
|
+
"channels": agent_channels,
|
|
317
|
+
"task": task,
|
|
318
|
+
}
|
|
319
|
+
client = await self._relay._ensure_started()
|
|
320
|
+
await self._relay._invoke_lifecycle_hook(
|
|
321
|
+
on_start,
|
|
322
|
+
context,
|
|
323
|
+
f'spawn("{agent_name}") on_start',
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
result = await client.spawn_pty(
|
|
328
|
+
name=agent_name,
|
|
329
|
+
cli=self._cli,
|
|
330
|
+
args=args or [],
|
|
331
|
+
channels=agent_channels,
|
|
332
|
+
task=task,
|
|
333
|
+
model=model,
|
|
334
|
+
cwd=cwd,
|
|
335
|
+
)
|
|
336
|
+
except Exception as error:
|
|
337
|
+
await self._relay._invoke_lifecycle_hook(
|
|
338
|
+
on_error,
|
|
339
|
+
{
|
|
340
|
+
**context,
|
|
341
|
+
"error": error,
|
|
342
|
+
},
|
|
343
|
+
f'spawn("{agent_name}") on_error',
|
|
344
|
+
)
|
|
345
|
+
raise
|
|
346
|
+
|
|
347
|
+
agent = Agent(
|
|
348
|
+
name=result.get("name", agent_name),
|
|
349
|
+
runtime=result.get("runtime", "pty"),
|
|
350
|
+
channels=agent_channels,
|
|
351
|
+
relay=self._relay,
|
|
352
|
+
)
|
|
353
|
+
self._relay._known_agents[agent.name] = agent
|
|
354
|
+
self._relay._reset_agent_lifecycle_state(agent.name)
|
|
355
|
+
await self._relay._invoke_lifecycle_hook(
|
|
356
|
+
on_success,
|
|
357
|
+
{
|
|
358
|
+
**context,
|
|
359
|
+
"name": agent.name,
|
|
360
|
+
"runtime": agent.runtime,
|
|
361
|
+
},
|
|
362
|
+
f'spawn("{agent_name}") on_success',
|
|
363
|
+
)
|
|
364
|
+
return agent
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ── AgentRelay facade ─────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class AgentRelay:
|
|
371
|
+
"""High-level facade for the Agent Relay SDK.
|
|
372
|
+
|
|
373
|
+
Example::
|
|
374
|
+
|
|
375
|
+
relay = AgentRelay(channels=["GTM"])
|
|
376
|
+
relay.on_message_received = lambda msg: print(f"[{msg.from_name}]: {msg.text}")
|
|
377
|
+
|
|
378
|
+
await relay.claude.spawn(name="Analyst", model="opus", channels=["GTM"], task="Analyze")
|
|
379
|
+
await relay.wait_for_agent_ready("Analyst")
|
|
380
|
+
await relay.shutdown()
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
binary_path: Optional[str] = None,
|
|
387
|
+
binary_args: Optional[list[str]] = None,
|
|
388
|
+
broker_name: Optional[str] = None,
|
|
389
|
+
channels: Optional[list[str]] = None,
|
|
390
|
+
cwd: Optional[str] = None,
|
|
391
|
+
env: Optional[dict[str, str]] = None,
|
|
392
|
+
request_timeout_ms: int = 10_000,
|
|
393
|
+
shutdown_timeout_ms: int = 3_000,
|
|
394
|
+
):
|
|
395
|
+
# Event hooks — assign a callback or None to clear
|
|
396
|
+
self.on_message_received: EventHook = None
|
|
397
|
+
self.on_message_sent: EventHook = None
|
|
398
|
+
self.on_agent_spawned: EventHook = None
|
|
399
|
+
self.on_agent_released: EventHook = None
|
|
400
|
+
self.on_agent_exited: EventHook = None
|
|
401
|
+
self.on_agent_ready: EventHook = None
|
|
402
|
+
self.on_worker_output: EventHook = None
|
|
403
|
+
self.on_delivery_update: EventHook = None
|
|
404
|
+
self.on_agent_exit_requested: EventHook = None
|
|
405
|
+
self.on_agent_idle: EventHook = None
|
|
406
|
+
|
|
407
|
+
self._default_channels = channels or ["general"]
|
|
408
|
+
self._client_kwargs: dict[str, Any] = {
|
|
409
|
+
"binary_path": binary_path,
|
|
410
|
+
"binary_args": binary_args,
|
|
411
|
+
"broker_name": broker_name,
|
|
412
|
+
"channels": self._default_channels,
|
|
413
|
+
"cwd": cwd,
|
|
414
|
+
"env": env,
|
|
415
|
+
"request_timeout_ms": request_timeout_ms,
|
|
416
|
+
"shutdown_timeout_ms": shutdown_timeout_ms,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
self._client: Optional[AgentRelayClient] = None
|
|
420
|
+
self._start_lock = asyncio.Lock()
|
|
421
|
+
self._unsubscribe_event: Optional[Callable[[], None]] = None
|
|
422
|
+
|
|
423
|
+
# Agent tracking
|
|
424
|
+
self._known_agents: dict[str, Agent] = {}
|
|
425
|
+
self._ready_agents: set[str] = set()
|
|
426
|
+
self._message_ready_agents: set[str] = set()
|
|
427
|
+
self._exited_agents: set[str] = set()
|
|
428
|
+
self._idle_agents: set[str] = set()
|
|
429
|
+
self._output_listeners: dict[str, list[Callable[[str], None]]] = {}
|
|
430
|
+
self._exit_resolvers: dict[str, list[asyncio.Future[str]]] = {}
|
|
431
|
+
self._idle_resolvers: dict[str, list[asyncio.Future[str]]] = {}
|
|
432
|
+
|
|
433
|
+
# Shorthand spawners
|
|
434
|
+
self.codex = AgentSpawner("codex", "Codex", self)
|
|
435
|
+
self.claude = AgentSpawner("claude", "Claude", self)
|
|
436
|
+
self.gemini = AgentSpawner("gemini", "Gemini", self)
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def workspace_key(self) -> Optional[str]:
|
|
440
|
+
return self._client.workspace_key if self._client else None
|
|
441
|
+
|
|
442
|
+
# ── Internal startup ──────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
async def _ensure_started(self) -> AgentRelayClient:
|
|
445
|
+
if self._client:
|
|
446
|
+
return self._client
|
|
447
|
+
async with self._start_lock:
|
|
448
|
+
if self._client:
|
|
449
|
+
return self._client
|
|
450
|
+
|
|
451
|
+
# Ensure env has RELAY_API_KEY if available
|
|
452
|
+
env = self._client_kwargs.get("env")
|
|
453
|
+
if env is None:
|
|
454
|
+
env_key = os.environ.get("RELAY_API_KEY")
|
|
455
|
+
if env_key:
|
|
456
|
+
self._client_kwargs["env"] = {**os.environ, "RELAY_API_KEY": env_key}
|
|
457
|
+
else:
|
|
458
|
+
self._client_kwargs["env"] = dict(os.environ)
|
|
459
|
+
else:
|
|
460
|
+
# Inject RELAY_API_KEY into custom env if not already present
|
|
461
|
+
env_key = os.environ.get("RELAY_API_KEY")
|
|
462
|
+
if env_key and "RELAY_API_KEY" not in env:
|
|
463
|
+
env["RELAY_API_KEY"] = env_key
|
|
464
|
+
|
|
465
|
+
# Remove None values to use defaults
|
|
466
|
+
kwargs = {k: v for k, v in self._client_kwargs.items() if v is not None}
|
|
467
|
+
client = AgentRelayClient(**kwargs)
|
|
468
|
+
await client.start_client()
|
|
469
|
+
|
|
470
|
+
self._client = client
|
|
471
|
+
if client.workspace_key:
|
|
472
|
+
pass # workspace_key is available via property
|
|
473
|
+
|
|
474
|
+
self._wire_events(client)
|
|
475
|
+
return client
|
|
476
|
+
|
|
477
|
+
# ── Spawning ──────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
async def spawn(
|
|
480
|
+
self,
|
|
481
|
+
name: str,
|
|
482
|
+
cli: str,
|
|
483
|
+
task: Optional[str] = None,
|
|
484
|
+
options: Optional[SpawnOptions] = None,
|
|
485
|
+
) -> Agent:
|
|
486
|
+
client = await self._ensure_started()
|
|
487
|
+
opts = options or SpawnOptions()
|
|
488
|
+
channels = opts.channels or ["general"]
|
|
489
|
+
context = {
|
|
490
|
+
"name": name,
|
|
491
|
+
"cli": cli,
|
|
492
|
+
"channels": channels,
|
|
493
|
+
"task": task,
|
|
494
|
+
}
|
|
495
|
+
await self._invoke_lifecycle_hook(
|
|
496
|
+
opts.on_start,
|
|
497
|
+
context,
|
|
498
|
+
f'spawn("{name}") on_start',
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
result = await client.spawn_pty(
|
|
503
|
+
name=name,
|
|
504
|
+
cli=cli,
|
|
505
|
+
task=task,
|
|
506
|
+
args=opts.args,
|
|
507
|
+
channels=channels,
|
|
508
|
+
model=opts.model,
|
|
509
|
+
cwd=opts.cwd,
|
|
510
|
+
team=opts.team,
|
|
511
|
+
shadow_of=opts.shadow_of,
|
|
512
|
+
shadow_mode=opts.shadow_mode,
|
|
513
|
+
idle_threshold_secs=opts.idle_threshold_secs,
|
|
514
|
+
restart_policy=opts.restart_policy,
|
|
515
|
+
)
|
|
516
|
+
except Exception as error:
|
|
517
|
+
await self._invoke_lifecycle_hook(
|
|
518
|
+
opts.on_error,
|
|
519
|
+
{
|
|
520
|
+
**context,
|
|
521
|
+
"error": error,
|
|
522
|
+
},
|
|
523
|
+
f'spawn("{name}") on_error',
|
|
524
|
+
)
|
|
525
|
+
raise
|
|
526
|
+
|
|
527
|
+
agent = Agent(
|
|
528
|
+
name=result.get("name", name),
|
|
529
|
+
runtime=result.get("runtime", "pty"),
|
|
530
|
+
channels=channels,
|
|
531
|
+
relay=self,
|
|
532
|
+
)
|
|
533
|
+
self._known_agents[agent.name] = agent
|
|
534
|
+
self._reset_agent_lifecycle_state(agent.name)
|
|
535
|
+
await self._invoke_lifecycle_hook(
|
|
536
|
+
opts.on_success,
|
|
537
|
+
{
|
|
538
|
+
**context,
|
|
539
|
+
"name": agent.name,
|
|
540
|
+
"runtime": agent.runtime,
|
|
541
|
+
},
|
|
542
|
+
f'spawn("{name}") on_success',
|
|
543
|
+
)
|
|
544
|
+
return agent
|
|
545
|
+
|
|
546
|
+
async def spawn_and_wait(
|
|
547
|
+
self,
|
|
548
|
+
name: str,
|
|
549
|
+
cli: str,
|
|
550
|
+
task: str,
|
|
551
|
+
options: Optional[SpawnOptions] = None,
|
|
552
|
+
timeout_ms: int = 60_000,
|
|
553
|
+
wait_for_message: bool = False,
|
|
554
|
+
) -> Agent:
|
|
555
|
+
agent = await self.spawn(name, cli, task, options)
|
|
556
|
+
if wait_for_message:
|
|
557
|
+
return await self.wait_for_agent_message(agent.name, timeout_ms)
|
|
558
|
+
return await self.wait_for_agent_ready(agent.name, timeout_ms)
|
|
559
|
+
|
|
560
|
+
# ── Human/system messaging ────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
def human(self, name: str) -> HumanHandle:
|
|
563
|
+
return HumanHandle(name, self)
|
|
564
|
+
|
|
565
|
+
def system(self) -> HumanHandle:
|
|
566
|
+
return HumanHandle("system", self)
|
|
567
|
+
|
|
568
|
+
async def broadcast(self, text: str, *, from_name: str = "human:orchestrator") -> Message:
|
|
569
|
+
return await self.human(from_name).send_message(to="*", text=text)
|
|
570
|
+
|
|
571
|
+
# ── Listing / status ──────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
async def list_agents(self) -> list[Agent]:
|
|
574
|
+
client = await self._ensure_started()
|
|
575
|
+
raw_list = await client.list_agents()
|
|
576
|
+
agents = []
|
|
577
|
+
for entry in raw_list:
|
|
578
|
+
name = entry.get("name", "")
|
|
579
|
+
existing = self._known_agents.get(name)
|
|
580
|
+
if existing:
|
|
581
|
+
agents.append(existing)
|
|
582
|
+
else:
|
|
583
|
+
agent = Agent(
|
|
584
|
+
name=name,
|
|
585
|
+
runtime=entry.get("runtime", "pty"),
|
|
586
|
+
channels=entry.get("channels", []),
|
|
587
|
+
relay=self,
|
|
588
|
+
)
|
|
589
|
+
self._known_agents[name] = agent
|
|
590
|
+
agents.append(agent)
|
|
591
|
+
return agents
|
|
592
|
+
|
|
593
|
+
async def preflight_agents(self, agents: list[dict[str, str]]) -> None:
|
|
594
|
+
client = await self._ensure_started()
|
|
595
|
+
await client.preflight_agents(agents)
|
|
596
|
+
|
|
597
|
+
async def get_status(self) -> dict[str, Any]:
|
|
598
|
+
client = await self._ensure_started()
|
|
599
|
+
return await client.get_status()
|
|
600
|
+
|
|
601
|
+
# ── Wait helpers ──────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
async def wait_for_agent_ready(self, name: str, timeout_ms: int = 60_000) -> Agent:
|
|
604
|
+
client = await self._ensure_started()
|
|
605
|
+
existing = self._known_agents.get(name)
|
|
606
|
+
if existing and name in self._ready_agents:
|
|
607
|
+
return existing
|
|
608
|
+
|
|
609
|
+
future: asyncio.Future[Agent] = asyncio.get_running_loop().create_future()
|
|
610
|
+
|
|
611
|
+
def on_event(event: BrokerEvent) -> None:
|
|
612
|
+
if event.get("kind") != "worker_ready" or event.get("name") != name:
|
|
613
|
+
return
|
|
614
|
+
agent = self._ensure_agent_handle(name, event.get("runtime", "pty"))
|
|
615
|
+
self._ready_agents.add(name)
|
|
616
|
+
self._exited_agents.discard(name)
|
|
617
|
+
if not future.done():
|
|
618
|
+
future.set_result(agent)
|
|
619
|
+
|
|
620
|
+
unsub = client.on_event(on_event)
|
|
621
|
+
try:
|
|
622
|
+
# Check again after subscribing (race condition guard)
|
|
623
|
+
known = self._known_agents.get(name)
|
|
624
|
+
if known and name in self._ready_agents:
|
|
625
|
+
return known
|
|
626
|
+
return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
|
|
627
|
+
except asyncio.TimeoutError:
|
|
628
|
+
raise TimeoutError(
|
|
629
|
+
f"Timed out waiting for worker_ready for '{name}' after {timeout_ms}ms"
|
|
630
|
+
) from None
|
|
631
|
+
finally:
|
|
632
|
+
unsub()
|
|
633
|
+
|
|
634
|
+
async def wait_for_agent_message(self, name: str, timeout_ms: int = 60_000) -> Agent:
|
|
635
|
+
client = await self._ensure_started()
|
|
636
|
+
existing = self._known_agents.get(name)
|
|
637
|
+
if existing and name in self._message_ready_agents:
|
|
638
|
+
return existing
|
|
639
|
+
|
|
640
|
+
future: asyncio.Future[Agent] = asyncio.get_running_loop().create_future()
|
|
641
|
+
|
|
642
|
+
def on_event(event: BrokerEvent) -> None:
|
|
643
|
+
if future.done():
|
|
644
|
+
return
|
|
645
|
+
if event.get("kind") == "relay_inbound" and event.get("from") == name:
|
|
646
|
+
self._message_ready_agents.add(name)
|
|
647
|
+
self._exited_agents.discard(name)
|
|
648
|
+
future.set_result(self._ensure_agent_handle(name))
|
|
649
|
+
elif event.get("kind") == "agent_exited" and event.get("name") == name:
|
|
650
|
+
future.set_exception(
|
|
651
|
+
RuntimeError(f"Agent '{name}' exited before sending its first relay message")
|
|
652
|
+
)
|
|
653
|
+
elif event.get("kind") == "agent_released" and event.get("name") == name:
|
|
654
|
+
future.set_exception(
|
|
655
|
+
RuntimeError(f"Agent '{name}' was released before sending its first relay message")
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
unsub = client.on_event(on_event)
|
|
659
|
+
try:
|
|
660
|
+
known = self._known_agents.get(name)
|
|
661
|
+
if known and name in self._message_ready_agents:
|
|
662
|
+
return known
|
|
663
|
+
return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
|
|
664
|
+
except asyncio.TimeoutError:
|
|
665
|
+
raise TimeoutError(
|
|
666
|
+
f"Timed out waiting for first relay message from '{name}' after {timeout_ms}ms"
|
|
667
|
+
) from None
|
|
668
|
+
finally:
|
|
669
|
+
unsub()
|
|
670
|
+
|
|
671
|
+
@staticmethod
|
|
672
|
+
async def wait_for_any(
|
|
673
|
+
agents: list[Agent], timeout_ms: Optional[int] = None
|
|
674
|
+
) -> tuple[Agent, str]:
|
|
675
|
+
"""Wait for any agent to exit. Returns (agent, result) tuple."""
|
|
676
|
+
if not agents:
|
|
677
|
+
raise ValueError("wait_for_any requires at least one agent")
|
|
678
|
+
|
|
679
|
+
async def _wait(agent: Agent) -> tuple[Agent, str]:
|
|
680
|
+
result = await agent.wait_for_exit(timeout_ms)
|
|
681
|
+
return (agent, result)
|
|
682
|
+
|
|
683
|
+
done, pending = await asyncio.wait(
|
|
684
|
+
[asyncio.create_task(_wait(a)) for a in agents],
|
|
685
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
686
|
+
)
|
|
687
|
+
for task in pending:
|
|
688
|
+
task.cancel()
|
|
689
|
+
return done.pop().result()
|
|
690
|
+
|
|
691
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
async def shutdown(self) -> None:
|
|
694
|
+
if self._unsubscribe_event:
|
|
695
|
+
self._unsubscribe_event()
|
|
696
|
+
self._unsubscribe_event = None
|
|
697
|
+
if self._client:
|
|
698
|
+
await self._client.shutdown()
|
|
699
|
+
self._client = None
|
|
700
|
+
|
|
701
|
+
self._known_agents.clear()
|
|
702
|
+
self._ready_agents.clear()
|
|
703
|
+
self._message_ready_agents.clear()
|
|
704
|
+
self._exited_agents.clear()
|
|
705
|
+
self._idle_agents.clear()
|
|
706
|
+
self._output_listeners.clear()
|
|
707
|
+
|
|
708
|
+
for futures in self._exit_resolvers.values():
|
|
709
|
+
for future in futures:
|
|
710
|
+
if not future.done():
|
|
711
|
+
future.set_result("released")
|
|
712
|
+
self._exit_resolvers.clear()
|
|
713
|
+
for futures in self._idle_resolvers.values():
|
|
714
|
+
for future in futures:
|
|
715
|
+
if not future.done():
|
|
716
|
+
future.set_result("exited")
|
|
717
|
+
self._idle_resolvers.clear()
|
|
718
|
+
|
|
719
|
+
# ── Private helpers ───────────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
async def _invoke_lifecycle_hook(
|
|
722
|
+
self,
|
|
723
|
+
hook: LifecycleHook,
|
|
724
|
+
context: dict[str, Any],
|
|
725
|
+
label: str,
|
|
726
|
+
) -> None:
|
|
727
|
+
if hook is None:
|
|
728
|
+
return
|
|
729
|
+
try:
|
|
730
|
+
result = hook(context)
|
|
731
|
+
if inspect.isawaitable(result):
|
|
732
|
+
await result
|
|
733
|
+
except Exception as error:
|
|
734
|
+
print(f"[AgentRelay] {label} hook threw: {error}")
|
|
735
|
+
|
|
736
|
+
def _reset_agent_lifecycle_state(self, name: str) -> None:
|
|
737
|
+
self._ready_agents.discard(name)
|
|
738
|
+
self._message_ready_agents.discard(name)
|
|
739
|
+
self._exited_agents.discard(name)
|
|
740
|
+
self._idle_agents.discard(name)
|
|
741
|
+
|
|
742
|
+
def _ensure_agent_handle(
|
|
743
|
+
self, name: str, runtime: AgentRuntime = "pty", channels: Optional[list[str]] = None,
|
|
744
|
+
) -> Agent:
|
|
745
|
+
existing = self._known_agents.get(name)
|
|
746
|
+
if existing:
|
|
747
|
+
return existing
|
|
748
|
+
agent = Agent(name, runtime, channels or [], self)
|
|
749
|
+
self._known_agents[name] = agent
|
|
750
|
+
return agent
|
|
751
|
+
|
|
752
|
+
def _wire_events(self, client: AgentRelayClient) -> None:
|
|
753
|
+
def on_event(event: BrokerEvent) -> None:
|
|
754
|
+
kind = event.get("kind")
|
|
755
|
+
name = event.get("name", "")
|
|
756
|
+
|
|
757
|
+
if kind == "relay_inbound":
|
|
758
|
+
from_name = event.get("from", "")
|
|
759
|
+
if from_name in self._known_agents:
|
|
760
|
+
self._message_ready_agents.add(from_name)
|
|
761
|
+
self._exited_agents.discard(from_name)
|
|
762
|
+
msg = Message(
|
|
763
|
+
event_id=event.get("event_id", ""),
|
|
764
|
+
from_name=event.get("from", ""),
|
|
765
|
+
to=event.get("target", ""),
|
|
766
|
+
text=event.get("body", ""),
|
|
767
|
+
thread_id=event.get("thread_id"),
|
|
768
|
+
)
|
|
769
|
+
if self.on_message_received:
|
|
770
|
+
self.on_message_received(msg)
|
|
771
|
+
|
|
772
|
+
elif kind == "agent_spawned":
|
|
773
|
+
agent = self._ensure_agent_handle(name, event.get("runtime", "pty"))
|
|
774
|
+
self._ready_agents.discard(name)
|
|
775
|
+
self._message_ready_agents.discard(name)
|
|
776
|
+
self._exited_agents.discard(name)
|
|
777
|
+
self._idle_agents.discard(name)
|
|
778
|
+
if self.on_agent_spawned:
|
|
779
|
+
self.on_agent_spawned(agent)
|
|
780
|
+
|
|
781
|
+
elif kind == "agent_released":
|
|
782
|
+
agent = self._known_agents.get(name) or self._ensure_agent_handle(name)
|
|
783
|
+
self._exited_agents.add(name)
|
|
784
|
+
self._ready_agents.discard(name)
|
|
785
|
+
self._message_ready_agents.discard(name)
|
|
786
|
+
self._idle_agents.discard(name)
|
|
787
|
+
if self.on_agent_released:
|
|
788
|
+
self.on_agent_released(agent)
|
|
789
|
+
self._known_agents.pop(name, None)
|
|
790
|
+
self._output_listeners.pop(name, None)
|
|
791
|
+
for future in self._exit_resolvers.pop(name, []):
|
|
792
|
+
if not future.done():
|
|
793
|
+
future.set_result("released")
|
|
794
|
+
for future in self._idle_resolvers.pop(name, []):
|
|
795
|
+
if not future.done():
|
|
796
|
+
future.set_result("exited")
|
|
797
|
+
|
|
798
|
+
elif kind == "agent_exited":
|
|
799
|
+
agent = self._known_agents.get(name) or self._ensure_agent_handle(name)
|
|
800
|
+
self._exited_agents.add(name)
|
|
801
|
+
self._ready_agents.discard(name)
|
|
802
|
+
self._message_ready_agents.discard(name)
|
|
803
|
+
self._idle_agents.discard(name)
|
|
804
|
+
agent.exit_code = event.get("code")
|
|
805
|
+
agent.exit_signal = event.get("signal")
|
|
806
|
+
if self.on_agent_exited:
|
|
807
|
+
self.on_agent_exited(agent)
|
|
808
|
+
self._known_agents.pop(name, None)
|
|
809
|
+
self._output_listeners.pop(name, None)
|
|
810
|
+
for future in self._exit_resolvers.pop(name, []):
|
|
811
|
+
if not future.done():
|
|
812
|
+
future.set_result("exited")
|
|
813
|
+
for future in self._idle_resolvers.pop(name, []):
|
|
814
|
+
if not future.done():
|
|
815
|
+
future.set_result("exited")
|
|
816
|
+
|
|
817
|
+
elif kind == "agent_exit":
|
|
818
|
+
agent = self._known_agents.get(name) or self._ensure_agent_handle(name)
|
|
819
|
+
agent.exit_reason = event.get("reason", "")
|
|
820
|
+
if self.on_agent_exit_requested:
|
|
821
|
+
self.on_agent_exit_requested({"name": name, "reason": event.get("reason", "")})
|
|
822
|
+
|
|
823
|
+
elif kind == "worker_ready":
|
|
824
|
+
agent = self._ensure_agent_handle(name, event.get("runtime", "pty"))
|
|
825
|
+
self._ready_agents.add(name)
|
|
826
|
+
self._exited_agents.discard(name)
|
|
827
|
+
self._idle_agents.discard(name)
|
|
828
|
+
if self.on_agent_ready:
|
|
829
|
+
self.on_agent_ready(agent)
|
|
830
|
+
|
|
831
|
+
elif kind == "worker_stream":
|
|
832
|
+
self._idle_agents.discard(name)
|
|
833
|
+
if self.on_worker_output:
|
|
834
|
+
self.on_worker_output({
|
|
835
|
+
"name": name,
|
|
836
|
+
"stream": event.get("stream", ""),
|
|
837
|
+
"chunk": event.get("chunk", ""),
|
|
838
|
+
})
|
|
839
|
+
# Per-agent output listeners
|
|
840
|
+
listeners = self._output_listeners.get(name, [])
|
|
841
|
+
for listener in listeners:
|
|
842
|
+
listener(event.get("chunk", ""))
|
|
843
|
+
|
|
844
|
+
elif kind == "agent_idle":
|
|
845
|
+
self._idle_agents.add(name)
|
|
846
|
+
if self.on_agent_idle:
|
|
847
|
+
self.on_agent_idle({
|
|
848
|
+
"name": name,
|
|
849
|
+
"idle_secs": event.get("idle_secs", 0),
|
|
850
|
+
})
|
|
851
|
+
for future in self._idle_resolvers.pop(name, []):
|
|
852
|
+
if not future.done():
|
|
853
|
+
future.set_result("idle")
|
|
854
|
+
|
|
855
|
+
# Delivery events
|
|
856
|
+
if kind and kind.startswith("delivery_"):
|
|
857
|
+
if self.on_delivery_update:
|
|
858
|
+
self.on_delivery_update(event)
|
|
859
|
+
|
|
860
|
+
self._unsubscribe_event = client.on_event(on_event)
|