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.
- nolag_agents-0.1.0/PKG-INFO +9 -0
- nolag_agents-0.1.0/nolag_agents/__init__.py +69 -0
- nolag_agents-0.1.0/nolag_agents/agent_room.py +303 -0
- nolag_agents-0.1.0/nolag_agents/client.py +207 -0
- nolag_agents-0.1.0/nolag_agents/constants.py +12 -0
- nolag_agents-0.1.0/nolag_agents/correlation.py +75 -0
- nolag_agents-0.1.0/nolag_agents/envelope.py +173 -0
- nolag_agents-0.1.0/nolag_agents/event_emitter.py +41 -0
- nolag_agents-0.1.0/nolag_agents/patterns/__init__.py +8 -0
- nolag_agents-0.1.0/nolag_agents/patterns/approve.py +100 -0
- nolag_agents-0.1.0/nolag_agents/patterns/blackboard.py +64 -0
- nolag_agents-0.1.0/nolag_agents/patterns/handoff.py +137 -0
- nolag_agents-0.1.0/nolag_agents/patterns/inbox.py +45 -0
- nolag_agents-0.1.0/nolag_agents/patterns/observe.py +58 -0
- nolag_agents-0.1.0/nolag_agents/patterns/tools.py +112 -0
- nolag_agents-0.1.0/nolag_agents/types.py +217 -0
- nolag_agents-0.1.0/nolag_agents/utils.py +18 -0
- nolag_agents-0.1.0/nolag_agents.egg-info/PKG-INFO +9 -0
- nolag_agents-0.1.0/nolag_agents.egg-info/SOURCES.txt +27 -0
- nolag_agents-0.1.0/nolag_agents.egg-info/dependency_links.txt +1 -0
- nolag_agents-0.1.0/nolag_agents.egg-info/requires.txt +5 -0
- nolag_agents-0.1.0/nolag_agents.egg-info/top_level.txt +1 -0
- nolag_agents-0.1.0/pyproject.toml +19 -0
- nolag_agents-0.1.0/setup.cfg +4 -0
- nolag_agents-0.1.0/tests/test_agent_room.py +136 -0
- nolag_agents-0.1.0/tests/test_client.py +45 -0
- nolag_agents-0.1.0/tests/test_correlation.py +62 -0
- nolag_agents-0.1.0/tests/test_envelope.py +119 -0
- 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
|