nolag-agents 0.1.0__tar.gz

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 (29) hide show
  1. nolag_agents-0.1.0/PKG-INFO +9 -0
  2. nolag_agents-0.1.0/nolag_agents/__init__.py +69 -0
  3. nolag_agents-0.1.0/nolag_agents/agent_room.py +303 -0
  4. nolag_agents-0.1.0/nolag_agents/client.py +207 -0
  5. nolag_agents-0.1.0/nolag_agents/constants.py +12 -0
  6. nolag_agents-0.1.0/nolag_agents/correlation.py +75 -0
  7. nolag_agents-0.1.0/nolag_agents/envelope.py +173 -0
  8. nolag_agents-0.1.0/nolag_agents/event_emitter.py +41 -0
  9. nolag_agents-0.1.0/nolag_agents/patterns/__init__.py +8 -0
  10. nolag_agents-0.1.0/nolag_agents/patterns/approve.py +100 -0
  11. nolag_agents-0.1.0/nolag_agents/patterns/blackboard.py +64 -0
  12. nolag_agents-0.1.0/nolag_agents/patterns/handoff.py +137 -0
  13. nolag_agents-0.1.0/nolag_agents/patterns/inbox.py +45 -0
  14. nolag_agents-0.1.0/nolag_agents/patterns/observe.py +58 -0
  15. nolag_agents-0.1.0/nolag_agents/patterns/tools.py +112 -0
  16. nolag_agents-0.1.0/nolag_agents/types.py +217 -0
  17. nolag_agents-0.1.0/nolag_agents/utils.py +18 -0
  18. nolag_agents-0.1.0/nolag_agents.egg-info/PKG-INFO +9 -0
  19. nolag_agents-0.1.0/nolag_agents.egg-info/SOURCES.txt +27 -0
  20. nolag_agents-0.1.0/nolag_agents.egg-info/dependency_links.txt +1 -0
  21. nolag_agents-0.1.0/nolag_agents.egg-info/requires.txt +5 -0
  22. nolag_agents-0.1.0/nolag_agents.egg-info/top_level.txt +1 -0
  23. nolag_agents-0.1.0/pyproject.toml +19 -0
  24. nolag_agents-0.1.0/setup.cfg +4 -0
  25. nolag_agents-0.1.0/tests/test_agent_room.py +136 -0
  26. nolag_agents-0.1.0/tests/test_client.py +45 -0
  27. nolag_agents-0.1.0/tests/test_correlation.py +62 -0
  28. nolag_agents-0.1.0/tests/test_envelope.py +119 -0
  29. nolag_agents-0.1.0/tests/test_event_emitter.py +77 -0
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: nolag-agents
3
+ Version: 0.1.0
4
+ Summary: Multi-agent coordination SDK for NoLag - handoff, blackboard, inbox, tools, approval, observe patterns
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: nolag>=2.1.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
9
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -0,0 +1,69 @@
1
+ """nolag-agents: Multi-agent coordination SDK for NoLag."""
2
+
3
+ from .client import NoLagAgents
4
+ from .agent_room import AgentRoom
5
+ from .event_emitter import EventEmitter
6
+ from .correlation import CorrelationManager
7
+ from .patterns.handoff import Handoff
8
+ from .patterns.blackboard import Blackboard
9
+ from .patterns.inbox import Inbox
10
+ from .patterns.tools import Tools
11
+ from .patterns.approve import Approve
12
+ from .patterns.observe import Observe
13
+ from .envelope import (
14
+ create_task_envelope,
15
+ create_result_envelope,
16
+ create_state_envelope,
17
+ create_event_envelope,
18
+ create_approval_request,
19
+ create_approval_response,
20
+ create_tool_request,
21
+ create_tool_response,
22
+ )
23
+ from .types import (
24
+ NoLagAgentsOptions,
25
+ AgentPresenceData,
26
+ TaskEnvelope,
27
+ ResultEnvelope,
28
+ StateEnvelope,
29
+ EventEnvelope,
30
+ ApprovalRequestEnvelope,
31
+ ApprovalResponseEnvelope,
32
+ ToolRequestEnvelope,
33
+ ToolResponseEnvelope,
34
+ ConnectedAgent,
35
+ InboxMessage,
36
+ )
37
+
38
+ __all__ = [
39
+ "NoLagAgents",
40
+ "AgentRoom",
41
+ "EventEmitter",
42
+ "CorrelationManager",
43
+ "Handoff",
44
+ "Blackboard",
45
+ "Inbox",
46
+ "Tools",
47
+ "Approve",
48
+ "Observe",
49
+ "create_task_envelope",
50
+ "create_result_envelope",
51
+ "create_state_envelope",
52
+ "create_event_envelope",
53
+ "create_approval_request",
54
+ "create_approval_response",
55
+ "create_tool_request",
56
+ "create_tool_response",
57
+ "NoLagAgentsOptions",
58
+ "AgentPresenceData",
59
+ "TaskEnvelope",
60
+ "ResultEnvelope",
61
+ "StateEnvelope",
62
+ "EventEnvelope",
63
+ "ApprovalRequestEnvelope",
64
+ "ApprovalResponseEnvelope",
65
+ "ToolRequestEnvelope",
66
+ "ToolResponseEnvelope",
67
+ "ConnectedAgent",
68
+ "InboxMessage",
69
+ ]
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any, Callable
5
+
6
+ from .event_emitter import EventEmitter
7
+ from .types import AgentPresenceData, ConnectedAgent
8
+ from .constants import (
9
+ TOPIC_TASKS,
10
+ TOPIC_RESULTS,
11
+ TOPIC_STATE,
12
+ TOPIC_EVENTS,
13
+ TOPIC_INBOX,
14
+ TOPIC_TOOLS,
15
+ TOPIC_APPROVAL,
16
+ )
17
+
18
+
19
+ class AgentRoom(EventEmitter):
20
+ """Wraps a nolag Room for agent coordination topics.
21
+
22
+ Provides typed pub/sub, presence-based service discovery,
23
+ and capability routing.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ name: str,
29
+ room_context: Any,
30
+ client: Any,
31
+ log: Callable[..., None],
32
+ agent_id: str,
33
+ presence: AgentPresenceData | None = None,
34
+ load_balance: bool = False,
35
+ load_balance_group: str | None = None,
36
+ load_balance_topics: list[str] | None = None,
37
+ ) -> None:
38
+ super().__init__()
39
+ self.name = name
40
+ self.agent_id = agent_id
41
+ self._room_context = room_context
42
+ self._client = client
43
+ self._log = log
44
+ self._presence = presence
45
+ self._agents: dict[str, ConnectedAgent] = {}
46
+ self._load_balance = load_balance
47
+ self._load_balance_group = load_balance_group
48
+ self._load_balance_topics = set(load_balance_topics or [TOPIC_TASKS, TOPIC_TOOLS])
49
+
50
+ self._wire_topic_listeners()
51
+ self._wire_presence_listeners()
52
+
53
+ async def initialize(self) -> None:
54
+ """Async initialization — subscribe to topics and set presence."""
55
+ from nolag import SubscribeOptions
56
+
57
+ all_topics = [
58
+ TOPIC_TASKS, TOPIC_RESULTS, TOPIC_STATE,
59
+ TOPIC_EVENTS, TOPIC_INBOX, TOPIC_TOOLS, TOPIC_APPROVAL,
60
+ ]
61
+ for topic in all_topics:
62
+ if self._load_balance and topic in self._load_balance_topics:
63
+ opts = SubscribeOptions(
64
+ load_balance=True,
65
+ load_balance_group=self._load_balance_group or self.agent_id,
66
+ )
67
+ self._log(f"subscribing to {topic} with load balance (group={opts.load_balance_group})")
68
+ await self._room_context.subscribe(topic, opts)
69
+ else:
70
+ await self._room_context.subscribe(topic)
71
+
72
+ if self._presence:
73
+ self._log(f"setting presence in room {self.name}:", self._presence)
74
+ await self._room_context.set_presence(self._presence.to_dict())
75
+
76
+ await self._fetch_initial_presence()
77
+
78
+ # ── Service Discovery ──
79
+
80
+ def get_connected_agents(self) -> list[ConnectedAgent]:
81
+ return list(self._agents.values())
82
+
83
+ def find_agents(self, capability: str) -> list[ConnectedAgent]:
84
+ return [a for a in self._agents.values() if capability in a.capabilities]
85
+
86
+ def has_capability(self, capability: str) -> bool:
87
+ return len(self.find_agents(capability)) > 0
88
+
89
+ def get_available_capabilities(self) -> list[str]:
90
+ caps: set[str] = set()
91
+ for agent in self._agents.values():
92
+ caps.update(agent.capabilities)
93
+ return list(caps)
94
+
95
+ # ── Presence ──
96
+
97
+ async def set_presence(self, data: AgentPresenceData) -> None:
98
+ self._presence = data
99
+ self._log(f"updating presence in room {self.name}")
100
+ await self._room_context.set_presence(data.to_dict())
101
+
102
+ async def fetch_presence(self) -> list[ConnectedAgent]:
103
+ try:
104
+ actors = await self._room_context.fetch_presence()
105
+ return [self._to_connected_agent(a) for a in (actors or [])]
106
+ except Exception:
107
+ return []
108
+
109
+ def emit_presence(
110
+ self,
111
+ event: str,
112
+ actor_id: str,
113
+ data: AgentPresenceData | None = None,
114
+ ) -> None:
115
+ if event == "presence_leave":
116
+ self._emit("presence_leave", actor_id)
117
+ else:
118
+ self._emit(event, actor_id, data or AgentPresenceData(name="", role="agent"))
119
+
120
+ # ── Publish ──
121
+
122
+ @property
123
+ def context(self) -> Any:
124
+ return self._room_context
125
+
126
+ async def publish_task(self, envelope: Any) -> None:
127
+ d = envelope.to_dict() if hasattr(envelope, "to_dict") else envelope
128
+ if not d.get("createdBy"):
129
+ d["createdBy"] = self.agent_id
130
+ await self._publish(TOPIC_TASKS, d)
131
+
132
+ async def publish_result(self, envelope: Any) -> None:
133
+ d = envelope.to_dict() if hasattr(envelope, "to_dict") else envelope
134
+ if not d.get("completedBy"):
135
+ d["completedBy"] = self.agent_id
136
+ await self._publish(TOPIC_RESULTS, d)
137
+
138
+ async def publish_state(self, data: dict[str, Any]) -> None:
139
+ if not data.get("updatedBy"):
140
+ data["updatedBy"] = self.agent_id
141
+ await self._publish(TOPIC_STATE, data, retain=True)
142
+
143
+ async def publish_event(self, data: dict[str, Any]) -> None:
144
+ if not data.get("emittedBy"):
145
+ data["emittedBy"] = self.agent_id
146
+ await self._publish(TOPIC_EVENTS, data)
147
+
148
+ async def publish_inbox(self, data: dict[str, Any]) -> None:
149
+ await self._publish(TOPIC_INBOX, data)
150
+
151
+ async def publish_tools(self, data: dict[str, Any]) -> None:
152
+ await self._publish(TOPIC_TOOLS, data)
153
+
154
+ async def publish_approval(self, data: dict[str, Any]) -> None:
155
+ await self._publish(TOPIC_APPROVAL, data, retain=True)
156
+
157
+ # ── Internals ──
158
+
159
+ async def _publish(self, topic: str, data: Any, retain: bool = False) -> None:
160
+ self._log(f"publish to {topic} in room {self.name}")
161
+ from .types import _to_camel_dict
162
+ from nolag import EmitOptions
163
+ if retain:
164
+ await self._room_context.emit(topic, data, EmitOptions(retain=True))
165
+ else:
166
+ await self._room_context.emit(topic, data)
167
+
168
+ def _to_connected_agent(self, actor: Any) -> ConnectedAgent:
169
+ if isinstance(actor, dict):
170
+ presence = actor.get("presence") or actor.get("data") or {}
171
+ return ConnectedAgent(
172
+ actor_id=actor.get("actor_token_id") or actor.get("actor_id") or "",
173
+ name=presence.get("name") or actor.get("actor_token_id") or "",
174
+ role=presence.get("role", "agent"),
175
+ capabilities=presence.get("capabilities", []),
176
+ metadata=presence.get("metadata"),
177
+ connected_at=actor.get("joined_at") or 0,
178
+ )
179
+ # ActorPresence object from SDK
180
+ if hasattr(actor, "actor_token_id"):
181
+ p = getattr(actor, "presence", {}) or {}
182
+ return ConnectedAgent(
183
+ actor_id=actor.actor_token_id,
184
+ name=p.get("name", actor.actor_token_id),
185
+ role=p.get("role", "agent"),
186
+ capabilities=p.get("capabilities", []),
187
+ metadata=p.get("metadata"),
188
+ connected_at=getattr(actor, "joined_at", 0) or 0,
189
+ )
190
+ return ConnectedAgent()
191
+
192
+ async def _fetch_initial_presence(self) -> None:
193
+ try:
194
+ actors = await self._room_context.fetch_presence()
195
+ if isinstance(actors, list):
196
+ for actor in actors:
197
+ connected = self._to_connected_agent(actor)
198
+ if connected.actor_id:
199
+ self._agents[connected.actor_id] = connected
200
+ self._log(f"discovered {len(self._agents)} agents in room {self.name}")
201
+ except Exception:
202
+ pass
203
+
204
+ def _wire_presence_listeners(self) -> None:
205
+ if not self._client:
206
+ return
207
+
208
+ def _on_join(actor: Any) -> None:
209
+ # SDK sends ActorPresence objects for presence:join
210
+ actor_id = getattr(actor, "actor_token_id", None)
211
+ if not actor_id:
212
+ return
213
+ p = getattr(actor, "presence", {}) or {}
214
+ agent = ConnectedAgent(
215
+ actor_id=actor_id,
216
+ name=p.get("name", actor_id),
217
+ role=p.get("role", "agent"),
218
+ capabilities=p.get("capabilities", []),
219
+ metadata=p.get("metadata"),
220
+ connected_at=0,
221
+ )
222
+ self._agents[actor_id] = agent
223
+ self._log(f"agent joined room {self.name}:", agent.name)
224
+ pdata = AgentPresenceData(
225
+ name=agent.name,
226
+ role=agent.role,
227
+ capabilities=agent.capabilities,
228
+ metadata=agent.metadata,
229
+ )
230
+ self._emit("presence_join", actor_id, pdata)
231
+
232
+ def _on_leave(actor: Any) -> None:
233
+ actor_id = getattr(actor, "actor_token_id", None)
234
+ if not actor_id:
235
+ return
236
+ self._agents.pop(actor_id, None)
237
+ self._log(f"agent left room {self.name}:", actor_id)
238
+ self._emit("presence_leave", actor_id)
239
+
240
+ def _on_update(actor: Any) -> None:
241
+ actor_id = getattr(actor, "actor_token_id", None)
242
+ if not actor_id:
243
+ return
244
+ p = getattr(actor, "presence", {}) or {}
245
+ existing = self._agents.get(actor_id)
246
+ agent = ConnectedAgent(
247
+ actor_id=actor_id,
248
+ name=p.get("name") or (existing.name if existing else actor_id),
249
+ role=p.get("role") or (existing.role if existing else "agent"),
250
+ capabilities=p.get("capabilities") or (existing.capabilities if existing else []),
251
+ metadata=p.get("metadata") or (existing.metadata if existing else None),
252
+ connected_at=existing.connected_at if existing else 0,
253
+ )
254
+ self._agents[actor_id] = agent
255
+ pdata = AgentPresenceData(
256
+ name=agent.name,
257
+ role=agent.role,
258
+ capabilities=agent.capabilities,
259
+ metadata=agent.metadata,
260
+ )
261
+ self._emit("presence_update", actor_id, pdata)
262
+
263
+ self._client.on("presence:join", _on_join)
264
+ self._client.on("presence:leave", _on_leave)
265
+ self._client.on("presence:update", _on_update)
266
+
267
+ def _wire_topic_listeners(self) -> None:
268
+ # Topic subscriptions are done in initialize() (async)
269
+ # Here we just wire the message handlers (sync .on() calls)
270
+ simple_map = [
271
+ (TOPIC_TASKS, "task"),
272
+ (TOPIC_RESULTS, "result"),
273
+ (TOPIC_STATE, "state_change"),
274
+ (TOPIC_EVENTS, "event"),
275
+ (TOPIC_INBOX, "inbox"),
276
+ ]
277
+ for topic, event_name in simple_map:
278
+ self._room_context.on(topic, self._make_handler(topic, event_name))
279
+
280
+ def _on_approval(data: Any, *_args: Any) -> None:
281
+ self._log(f"received {TOPIC_APPROVAL} in room {self.name}")
282
+ d = data if isinstance(data, dict) else {}
283
+ if d.get("type") == "approval_response":
284
+ self._emit("approval_response", data)
285
+ else:
286
+ self._emit("approval_request", data)
287
+
288
+ def _on_tools(data: Any, *_args: Any) -> None:
289
+ self._log(f"received {TOPIC_TOOLS} in room {self.name}")
290
+ d = data if isinstance(data, dict) else {}
291
+ if d.get("type") == "tool_response":
292
+ self._emit("tool_response", data)
293
+ else:
294
+ self._emit("tool_request", data)
295
+
296
+ self._room_context.on(TOPIC_APPROVAL, _on_approval)
297
+ self._room_context.on(TOPIC_TOOLS, _on_tools)
298
+
299
+ def _make_handler(self, topic: str, event_name: str) -> Callable[..., None]:
300
+ def handler(data: Any, *_args: Any) -> None:
301
+ self._log(f"received {topic} in room {self.name}")
302
+ self._emit(event_name, data)
303
+ return handler
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from nolag import NoLag as NoLagClient
7
+
8
+ from .event_emitter import EventEmitter
9
+ from .agent_room import AgentRoom
10
+ from .utils import generate_id, create_logger
11
+ from .constants import DEFAULT_APP_NAME, DEFAULT_ROOM
12
+ from .types import NoLagAgentsOptions, AgentPresenceData
13
+
14
+
15
+ class NoLagAgents(EventEmitter):
16
+ """High-level agent coordination SDK built on the nolag Python SDK.
17
+
18
+ Provides typed rooms for multi-agent patterns: Handoff, Blackboard,
19
+ Inbox, Tools, Approval, and Observe.
20
+ """
21
+
22
+ def __init__(self, token: str, options: NoLagAgentsOptions | None = None) -> None:
23
+ super().__init__()
24
+ opts = options or NoLagAgentsOptions()
25
+ self._token = token
26
+ self._app_name = opts.app_name or DEFAULT_APP_NAME
27
+ self._agent_id = opts.agent_id or generate_id()
28
+ self._debug = opts.debug
29
+ self._room_names = opts.rooms or [DEFAULT_ROOM]
30
+ self._lobby = opts.lobby
31
+ self._presence = opts.presence
32
+ self._client_options = opts.client_options or {}
33
+ self._load_balance = opts.load_balance
34
+ self._load_balance_group = opts.load_balance_group
35
+ self._load_balance_topics = opts.load_balance_topics
36
+
37
+ self._client: Any = None
38
+ self._app_context: Any = None
39
+ self._rooms: dict[str, AgentRoom] = {}
40
+ self._connected = False
41
+ self._log = create_logger("NoLagAgents", self._debug)
42
+
43
+ @property
44
+ def agent_id(self) -> str:
45
+ return self._agent_id
46
+
47
+ @property
48
+ def connected(self) -> bool:
49
+ return self._connected
50
+
51
+ @property
52
+ def rooms(self) -> dict[str, AgentRoom]:
53
+ return dict(self._rooms)
54
+
55
+ async def connect(self) -> None:
56
+ self._log("connecting...")
57
+
58
+ self._client = NoLagClient(self._token, **self._client_options)
59
+
60
+ self._app_context = self._client.set_app(self._app_name)
61
+
62
+ self._client.on("connect", self._on_connected)
63
+ self._client.on("disconnect", self._on_disconnected)
64
+ self._client.on("reconnect", self._on_reconnected)
65
+ self._client.on("error", self._on_error)
66
+
67
+ await self._client.connect()
68
+
69
+ for room_name in self._room_names:
70
+ await self.room(room_name)
71
+
72
+ if self._lobby:
73
+ await self.subscribe_lobby(self._lobby)
74
+
75
+ async def subscribe_lobby(self, lobby_slug: str) -> dict[str, Any]:
76
+ if not self._app_context:
77
+ raise RuntimeError("Not connected. Call connect() before subscribing to lobbies.")
78
+
79
+ self._log(f"subscribing to lobby: {lobby_slug}")
80
+ lobby = self._app_context.set_lobby(lobby_slug)
81
+
82
+ def _on_lobby_join(evt: Any) -> None:
83
+ d = evt if isinstance(evt, dict) else {}
84
+ actor_id = d.get("actor_id")
85
+ data = d.get("data", {})
86
+ if actor_id:
87
+ self._log(f"lobby presence:join - {data.get('name', actor_id)}")
88
+ for rm in self._rooms.values():
89
+ if actor_id not in rm._agents:
90
+ from .types import ConnectedAgent
91
+ rm._agents[actor_id] = ConnectedAgent(
92
+ actor_id=actor_id,
93
+ name=data.get("name", actor_id),
94
+ role=data.get("role", "agent"),
95
+ capabilities=data.get("capabilities", []),
96
+ metadata=data.get("metadata"),
97
+ connected_at=int(time.time() * 1000),
98
+ )
99
+ pdata = AgentPresenceData(
100
+ name=data.get("name", actor_id),
101
+ role=data.get("role", "agent"),
102
+ capabilities=data.get("capabilities", []),
103
+ metadata=data.get("metadata"),
104
+ )
105
+ rm.emit_presence("presence_join", actor_id, pdata)
106
+
107
+ def _on_lobby_leave(evt: Any) -> None:
108
+ d = evt if isinstance(evt, dict) else {}
109
+ actor_id = d.get("actor_id")
110
+ if actor_id:
111
+ self._log(f"lobby presence:leave - {actor_id}")
112
+ for rm in self._rooms.values():
113
+ rm._agents.pop(actor_id, None)
114
+ rm.emit_presence("presence_leave", actor_id)
115
+
116
+ def _on_lobby_update(evt: Any) -> None:
117
+ d = evt if isinstance(evt, dict) else {}
118
+ actor_id = d.get("actor_id")
119
+ data = d.get("data", {})
120
+ if actor_id:
121
+ for rm in self._rooms.values():
122
+ existing = rm._agents.get(actor_id)
123
+ if existing:
124
+ if data.get("name"):
125
+ existing.name = data["name"]
126
+ if data.get("role"):
127
+ existing.role = data["role"]
128
+ if data.get("capabilities"):
129
+ existing.capabilities = data["capabilities"]
130
+ if data.get("metadata"):
131
+ existing.metadata = data["metadata"]
132
+ pdata = AgentPresenceData(
133
+ name=data.get("name", ""),
134
+ role=data.get("role", "agent"),
135
+ capabilities=data.get("capabilities", []),
136
+ metadata=data.get("metadata"),
137
+ )
138
+ rm.emit_presence("presence_update", actor_id, pdata)
139
+
140
+ self._client.on("lobbyPresence:join", _on_lobby_join)
141
+ self._client.on("lobbyPresence:leave", _on_lobby_leave)
142
+ self._client.on("lobbyPresence:update", _on_lobby_update)
143
+
144
+ try:
145
+ initial_state = await lobby.subscribe()
146
+ self._log(f"lobby subscribed, initial state: {list((initial_state or {}).keys())}")
147
+ return initial_state or {}
148
+ except Exception as err:
149
+ self._log(f"lobby subscription failed: {err}")
150
+ return {}
151
+
152
+ def disconnect(self) -> None:
153
+ self._log("disconnecting...")
154
+ if self._client:
155
+ self._client.disconnect()
156
+ self._rooms.clear()
157
+ self._client = None
158
+ self._app_context = None
159
+ self._connected = False
160
+
161
+ async def room(self, name: str) -> AgentRoom:
162
+ agent_room = self._rooms.get(name)
163
+ if agent_room:
164
+ return agent_room
165
+
166
+ if not self._app_context:
167
+ raise RuntimeError("Not connected. Call connect() before accessing rooms.")
168
+
169
+ self._log(f"joining room: {name}")
170
+ room_context = self._app_context.set_room(name)
171
+ agent_room = AgentRoom(
172
+ name=name,
173
+ room_context=room_context,
174
+ client=self._client,
175
+ log=self._log,
176
+ agent_id=self._agent_id,
177
+ presence=self._presence,
178
+ load_balance=self._load_balance,
179
+ load_balance_group=self._load_balance_group,
180
+ load_balance_topics=self._load_balance_topics,
181
+ )
182
+ await agent_room.initialize()
183
+ self._rooms[name] = agent_room
184
+ return agent_room
185
+
186
+ # ── Internal event handlers ──
187
+
188
+ def _on_connected(self, *_args: Any) -> None:
189
+ self._connected = True
190
+ self._log("connected")
191
+ self._emit("connected")
192
+
193
+ def _on_disconnected(self, *args: Any) -> None:
194
+ self._connected = False
195
+ reason = args[0] if args else "unknown"
196
+ self._log("disconnected:", reason)
197
+ self._emit("disconnected", reason)
198
+
199
+ def _on_reconnected(self, *_args: Any) -> None:
200
+ self._connected = True
201
+ self._log("reconnected")
202
+ self._emit("reconnected")
203
+
204
+ def _on_error(self, *args: Any) -> None:
205
+ err = args[0] if args else Exception("unknown error")
206
+ self._log("error:", err)
207
+ self._emit("error", err)
@@ -0,0 +1,12 @@
1
+ DEFAULT_APP_NAME = "agents"
2
+
3
+ TOPIC_TASKS = "tasks"
4
+ TOPIC_RESULTS = "results"
5
+ TOPIC_STATE = "state"
6
+ TOPIC_EVENTS = "events"
7
+ TOPIC_INBOX = "inbox"
8
+ TOPIC_TOOLS = "tools"
9
+ TOPIC_APPROVAL = "approval"
10
+
11
+ DEFAULT_ROOM = "default-workflow"
12
+ LOBBY_AGENT_ACTIVITY = "agent-activity"
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ class CorrelationManager(Generic[T]):
10
+ """Maps correlation IDs to pending asyncio.Future with optional timeout."""
11
+
12
+ def __init__(self) -> None:
13
+ self._pending: dict[str, _Entry] = {}
14
+
15
+ def register(self, correlation_id: str, timeout_ms: int | None = None) -> asyncio.Future[T]:
16
+ loop = asyncio.get_running_loop()
17
+ future: asyncio.Future[T] = loop.create_future()
18
+ handle: asyncio.TimerHandle | None = None
19
+
20
+ if timeout_ms and timeout_ms > 0:
21
+ def _on_timeout():
22
+ self._pending.pop(correlation_id, None)
23
+ if not future.done():
24
+ future.set_exception(
25
+ TimeoutError(
26
+ f"Correlation {correlation_id} timed out after {timeout_ms}ms"
27
+ )
28
+ )
29
+ handle = loop.call_later(timeout_ms / 1000.0, _on_timeout)
30
+
31
+ self._pending[correlation_id] = _Entry(future=future, handle=handle)
32
+ return future
33
+
34
+ def resolve(self, correlation_id: str, value: T) -> bool:
35
+ entry = self._pending.pop(correlation_id, None)
36
+ if entry is None:
37
+ return False
38
+ if entry.handle:
39
+ entry.handle.cancel()
40
+ if not entry.future.done():
41
+ entry.future.set_result(value)
42
+ return True
43
+
44
+ def reject(self, correlation_id: str, error: Exception) -> bool:
45
+ entry = self._pending.pop(correlation_id, None)
46
+ if entry is None:
47
+ return False
48
+ if entry.handle:
49
+ entry.handle.cancel()
50
+ if not entry.future.done():
51
+ entry.future.set_exception(error)
52
+ return True
53
+
54
+ def has(self, correlation_id: str) -> bool:
55
+ return correlation_id in self._pending
56
+
57
+ def clear(self) -> None:
58
+ for cid, entry in list(self._pending.items()):
59
+ if entry.handle:
60
+ entry.handle.cancel()
61
+ if not entry.future.done():
62
+ entry.future.set_exception(Exception(f"Correlation {cid} cancelled"))
63
+ self._pending.clear()
64
+
65
+ @property
66
+ def size(self) -> int:
67
+ return len(self._pending)
68
+
69
+
70
+ class _Entry:
71
+ __slots__ = ("future", "handle")
72
+
73
+ def __init__(self, future: asyncio.Future, handle: asyncio.TimerHandle | None) -> None:
74
+ self.future = future
75
+ self.handle = handle