ringforge 0.1.0__py3-none-any.whl
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.
- ringforge/__init__.py +47 -0
- ringforge/activity.py +61 -0
- ringforge/client.py +405 -0
- ringforge/groups.py +61 -0
- ringforge/memory.py +77 -0
- ringforge/messaging.py +38 -0
- ringforge/presence.py +43 -0
- ringforge/tasks.py +63 -0
- ringforge/types.py +243 -0
- ringforge-0.1.0.dist-info/METADATA +283 -0
- ringforge-0.1.0.dist-info/RECORD +12 -0
- ringforge-0.1.0.dist-info/WHEEL +4 -0
ringforge/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Ringforge SDK — Python client for the Ringforge agent mesh platform."""
|
|
2
|
+
|
|
3
|
+
from ringforge.client import RingforgeClient
|
|
4
|
+
from ringforge.types import (
|
|
5
|
+
RingforgeConfig,
|
|
6
|
+
AgentConfig,
|
|
7
|
+
AgentState,
|
|
8
|
+
PresenceAgent,
|
|
9
|
+
ActivityKind,
|
|
10
|
+
ActivityEvent,
|
|
11
|
+
BroadcastActivityParams,
|
|
12
|
+
MemoryEntry,
|
|
13
|
+
SetMemoryParams,
|
|
14
|
+
QueryMemoryParams,
|
|
15
|
+
DirectMessage,
|
|
16
|
+
SendMessageParams,
|
|
17
|
+
Group,
|
|
18
|
+
CreateGroupParams,
|
|
19
|
+
TaskSubmitParams,
|
|
20
|
+
TaskResult,
|
|
21
|
+
TaskStatus,
|
|
22
|
+
TaskEvent,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"RingforgeClient",
|
|
29
|
+
"RingforgeConfig",
|
|
30
|
+
"AgentConfig",
|
|
31
|
+
"AgentState",
|
|
32
|
+
"PresenceAgent",
|
|
33
|
+
"ActivityKind",
|
|
34
|
+
"ActivityEvent",
|
|
35
|
+
"BroadcastActivityParams",
|
|
36
|
+
"MemoryEntry",
|
|
37
|
+
"SetMemoryParams",
|
|
38
|
+
"QueryMemoryParams",
|
|
39
|
+
"DirectMessage",
|
|
40
|
+
"SendMessageParams",
|
|
41
|
+
"Group",
|
|
42
|
+
"CreateGroupParams",
|
|
43
|
+
"TaskSubmitParams",
|
|
44
|
+
"TaskResult",
|
|
45
|
+
"TaskStatus",
|
|
46
|
+
"TaskEvent",
|
|
47
|
+
]
|
ringforge/activity.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Activity sub-API for broadcasting and subscribing to fleet events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ringforge.types import ActivityEvent, BroadcastActivityParams
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ringforge.client import RingforgeClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ActivityAPI:
|
|
15
|
+
"""Broadcast and subscribe to fleet-wide activity events."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: RingforgeClient) -> None:
|
|
18
|
+
self._client = client
|
|
19
|
+
|
|
20
|
+
async def broadcast(
|
|
21
|
+
self,
|
|
22
|
+
params: BroadcastActivityParams,
|
|
23
|
+
idempotency_key: str | None = None,
|
|
24
|
+
) -> dict[str, str]:
|
|
25
|
+
"""Broadcast an activity event to the fleet."""
|
|
26
|
+
payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
|
|
27
|
+
return await self._client.push(
|
|
28
|
+
"activity:broadcast",
|
|
29
|
+
{"payload": payload},
|
|
30
|
+
idempotency_key=idempotency_key,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
async def history(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
limit: int | None = None,
|
|
37
|
+
kinds: list[str] | None = None,
|
|
38
|
+
) -> list[ActivityEvent]:
|
|
39
|
+
"""Get recent activity history."""
|
|
40
|
+
opts: dict[str, Any] = {}
|
|
41
|
+
if limit is not None:
|
|
42
|
+
opts["limit"] = limit
|
|
43
|
+
if kinds is not None:
|
|
44
|
+
opts["kinds"] = kinds
|
|
45
|
+
resp = await self._client.push("activity:history", {"payload": opts})
|
|
46
|
+
events_data = resp.get("payload", {}).get("events", [])
|
|
47
|
+
return [ActivityEvent(**e) for e in events_data]
|
|
48
|
+
|
|
49
|
+
async def subscribe(self, tags: list[str]) -> list[str]:
|
|
50
|
+
"""Subscribe to tagged activity events."""
|
|
51
|
+
resp = await self._client.push(
|
|
52
|
+
"activity:subscribe", {"payload": {"tags": tags}}
|
|
53
|
+
)
|
|
54
|
+
return resp.get("subscribed_tags", [])
|
|
55
|
+
|
|
56
|
+
async def unsubscribe(self, tags: list[str]) -> list[str]:
|
|
57
|
+
"""Unsubscribe from tagged activity events."""
|
|
58
|
+
resp = await self._client.push(
|
|
59
|
+
"activity:unsubscribe", {"payload": {"tags": tags}}
|
|
60
|
+
)
|
|
61
|
+
return resp.get("subscribed_tags", [])
|
ringforge/client.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Ringforge async WebSocket client using the Phoenix Channel v2 wire protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable, Coroutine
|
|
10
|
+
|
|
11
|
+
import websockets
|
|
12
|
+
import websockets.exceptions
|
|
13
|
+
|
|
14
|
+
from ringforge.types import RingforgeConfig
|
|
15
|
+
from ringforge.presence import PresenceAPI
|
|
16
|
+
from ringforge.activity import ActivityAPI
|
|
17
|
+
from ringforge.memory import MemoryAPI
|
|
18
|
+
from ringforge.messaging import DirectMessageAPI
|
|
19
|
+
from ringforge.groups import GroupAPI
|
|
20
|
+
from ringforge.tasks import TasksAPI
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("ringforge")
|
|
23
|
+
|
|
24
|
+
# Type alias for event handlers
|
|
25
|
+
EventHandler = Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RingforgeClient:
|
|
29
|
+
"""Async client for the Ringforge agent mesh platform.
|
|
30
|
+
|
|
31
|
+
Implements the Phoenix Channel v2 JSON wire protocol over WebSocket.
|
|
32
|
+
Messages are JSON arrays: ``[join_ref, ref, topic, event, payload]``
|
|
33
|
+
|
|
34
|
+
Example::
|
|
35
|
+
|
|
36
|
+
from ringforge import RingforgeClient, RingforgeConfig, AgentConfig
|
|
37
|
+
|
|
38
|
+
config = RingforgeConfig(
|
|
39
|
+
server="wss://ringforge.example.com/ws/websocket",
|
|
40
|
+
api_key="rf_live_...",
|
|
41
|
+
agent=AgentConfig(name="my-agent", capabilities=["search"]),
|
|
42
|
+
)
|
|
43
|
+
client = RingforgeClient(config)
|
|
44
|
+
await client.connect()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: RingforgeConfig) -> None:
|
|
48
|
+
self._config = config
|
|
49
|
+
self._ws: websockets.WebSocketClientProtocol | None = None
|
|
50
|
+
self._ref: int = 0
|
|
51
|
+
self._agent_id: str | None = None
|
|
52
|
+
self._fleet_id: str | None = None
|
|
53
|
+
self._connected = False
|
|
54
|
+
self._running = False
|
|
55
|
+
self._reconnect_attempts = 0
|
|
56
|
+
|
|
57
|
+
# Pending replies: ref → Future
|
|
58
|
+
self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
|
59
|
+
|
|
60
|
+
# Event listeners: event_name → set of handlers
|
|
61
|
+
self._listeners: dict[str, set[EventHandler]] = {}
|
|
62
|
+
|
|
63
|
+
# Idempotency cache: key → (result, expires_at)
|
|
64
|
+
self._idempotency_cache: dict[str, tuple[Any, float]] = {}
|
|
65
|
+
|
|
66
|
+
# Background tasks
|
|
67
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
68
|
+
self._receive_task: asyncio.Task[None] | None = None
|
|
69
|
+
|
|
70
|
+
# Sub-APIs
|
|
71
|
+
self.presence = PresenceAPI(self)
|
|
72
|
+
self.activity = ActivityAPI(self)
|
|
73
|
+
self.memory = MemoryAPI(self)
|
|
74
|
+
self.dm = DirectMessageAPI(self)
|
|
75
|
+
self.groups = GroupAPI(self)
|
|
76
|
+
self.tasks = TasksAPI(self)
|
|
77
|
+
|
|
78
|
+
# ── Properties ────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def agent_id(self) -> str | None:
|
|
82
|
+
"""Agent ID assigned after connection."""
|
|
83
|
+
return self._agent_id
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def fleet_id(self) -> str | None:
|
|
87
|
+
"""Fleet ID resolved after connection."""
|
|
88
|
+
return self._fleet_id
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def connected(self) -> bool:
|
|
92
|
+
"""Whether the client is connected to the fleet."""
|
|
93
|
+
return self._connected
|
|
94
|
+
|
|
95
|
+
# ── Connection ────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
async def connect(self) -> str:
|
|
98
|
+
"""Connect to the Ringforge fleet.
|
|
99
|
+
|
|
100
|
+
Returns the assigned agent ID.
|
|
101
|
+
"""
|
|
102
|
+
self._running = True
|
|
103
|
+
await self._do_connect()
|
|
104
|
+
assert self._agent_id is not None
|
|
105
|
+
return self._agent_id
|
|
106
|
+
|
|
107
|
+
async def _do_connect(self) -> None:
|
|
108
|
+
"""Establish WebSocket connection and join the fleet channel."""
|
|
109
|
+
url = self._config.server
|
|
110
|
+
try:
|
|
111
|
+
self._ws = await websockets.connect(url)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
self._emit_sync("error", exc)
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
# Start receive loop
|
|
117
|
+
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
118
|
+
|
|
119
|
+
# Join the fleet channel
|
|
120
|
+
topic = (
|
|
121
|
+
f"fleet:{self._config.fleet_id}"
|
|
122
|
+
if self._config.fleet_id
|
|
123
|
+
else "fleet:lobby"
|
|
124
|
+
)
|
|
125
|
+
self._topic = topic
|
|
126
|
+
|
|
127
|
+
join_payload = {
|
|
128
|
+
"api_key": self._config.api_key,
|
|
129
|
+
"agent": {
|
|
130
|
+
"name": self._config.agent.name,
|
|
131
|
+
"framework": self._config.agent.framework,
|
|
132
|
+
"capabilities": self._config.agent.capabilities,
|
|
133
|
+
},
|
|
134
|
+
"payload": {
|
|
135
|
+
"state": self._config.agent.state,
|
|
136
|
+
"task": self._config.agent.task,
|
|
137
|
+
"metadata": self._config.agent.metadata,
|
|
138
|
+
"name": self._config.agent.name,
|
|
139
|
+
"framework": self._config.agent.framework,
|
|
140
|
+
"capabilities": self._config.agent.capabilities,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
resp = await self._push_raw(topic, "phx_join", join_payload, join_ref="join")
|
|
145
|
+
status = resp.get("status", "")
|
|
146
|
+
response = resp.get("response", {})
|
|
147
|
+
|
|
148
|
+
if status != "ok":
|
|
149
|
+
raise ConnectionError(f"Fleet join failed: {resp}")
|
|
150
|
+
|
|
151
|
+
self._agent_id = response.get("agent_id")
|
|
152
|
+
self._fleet_id = response.get("fleet_id") or self._config.fleet_id
|
|
153
|
+
self._connected = True
|
|
154
|
+
self._reconnect_attempts = 0
|
|
155
|
+
|
|
156
|
+
# Start heartbeat
|
|
157
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
158
|
+
|
|
159
|
+
self._emit_sync("connected", self._agent_id)
|
|
160
|
+
|
|
161
|
+
async def disconnect(self) -> None:
|
|
162
|
+
"""Disconnect from the fleet."""
|
|
163
|
+
self._running = False
|
|
164
|
+
self._connected = False
|
|
165
|
+
|
|
166
|
+
if self._heartbeat_task:
|
|
167
|
+
self._heartbeat_task.cancel()
|
|
168
|
+
self._heartbeat_task = None
|
|
169
|
+
|
|
170
|
+
if self._receive_task:
|
|
171
|
+
self._receive_task.cancel()
|
|
172
|
+
self._receive_task = None
|
|
173
|
+
|
|
174
|
+
if self._ws:
|
|
175
|
+
try:
|
|
176
|
+
# Send leave
|
|
177
|
+
ref = self._next_ref()
|
|
178
|
+
msg = json.dumps([None, ref, self._topic, "phx_leave", {}])
|
|
179
|
+
await self._ws.send(msg)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
await self._ws.close()
|
|
183
|
+
self._ws = None
|
|
184
|
+
|
|
185
|
+
self._agent_id = None
|
|
186
|
+
self._fleet_id = None
|
|
187
|
+
self._emit_sync("disconnected", "client_disconnect")
|
|
188
|
+
|
|
189
|
+
# ── Event System ──────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def on(self, event: str, handler: EventHandler) -> "RingforgeClient":
|
|
192
|
+
"""Register an event handler."""
|
|
193
|
+
if event not in self._listeners:
|
|
194
|
+
self._listeners[event] = set()
|
|
195
|
+
self._listeners[event].add(handler)
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def off(self, event: str, handler: EventHandler) -> "RingforgeClient":
|
|
199
|
+
"""Remove an event handler."""
|
|
200
|
+
if event in self._listeners:
|
|
201
|
+
self._listeners[event].discard(handler)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def _emit_sync(self, event: str, *args: Any) -> None:
|
|
205
|
+
"""Emit an event to all registered handlers."""
|
|
206
|
+
handlers = self._listeners.get(event, set())
|
|
207
|
+
for handler in handlers:
|
|
208
|
+
try:
|
|
209
|
+
result = handler(*args)
|
|
210
|
+
# If handler is a coroutine, schedule it
|
|
211
|
+
if asyncio.iscoroutine(result):
|
|
212
|
+
asyncio.ensure_future(result)
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
logger.error("Error in %s handler: %s", event, exc)
|
|
215
|
+
|
|
216
|
+
# ── Push (public) ─────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
async def push(
|
|
219
|
+
self,
|
|
220
|
+
event: str,
|
|
221
|
+
payload: dict[str, Any],
|
|
222
|
+
idempotency_key: str | None = None,
|
|
223
|
+
) -> dict[str, Any]:
|
|
224
|
+
"""Push a message to the fleet channel.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
event: Channel event name (e.g. ``"presence:update"``).
|
|
228
|
+
payload: Event payload dict.
|
|
229
|
+
idempotency_key: Optional key for deduplication (cached 5 min).
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Server response payload.
|
|
233
|
+
"""
|
|
234
|
+
if not self._connected or not self._ws:
|
|
235
|
+
raise RuntimeError("Not connected")
|
|
236
|
+
|
|
237
|
+
# Check idempotency cache
|
|
238
|
+
if idempotency_key:
|
|
239
|
+
cached = self._idempotency_cache.get(idempotency_key)
|
|
240
|
+
if cached and cached[1] > time.monotonic():
|
|
241
|
+
return cached[0]
|
|
242
|
+
payload = {**payload, "_idempotency_key": idempotency_key}
|
|
243
|
+
|
|
244
|
+
resp = await self._push_raw(self._topic, event, payload)
|
|
245
|
+
status = resp.get("status", "")
|
|
246
|
+
response = resp.get("response", resp)
|
|
247
|
+
|
|
248
|
+
if status == "error":
|
|
249
|
+
raise RuntimeError(f"Push '{event}' failed: {json.dumps(response)}")
|
|
250
|
+
|
|
251
|
+
# Cache idempotent result
|
|
252
|
+
if idempotency_key:
|
|
253
|
+
self._idempotency_cache[idempotency_key] = (
|
|
254
|
+
response,
|
|
255
|
+
time.monotonic() + 300,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return response
|
|
259
|
+
|
|
260
|
+
# ── Wire Protocol ─────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def _next_ref(self) -> str:
|
|
263
|
+
"""Generate the next message reference."""
|
|
264
|
+
self._ref += 1
|
|
265
|
+
return str(self._ref)
|
|
266
|
+
|
|
267
|
+
async def _push_raw(
|
|
268
|
+
self,
|
|
269
|
+
topic: str,
|
|
270
|
+
event: str,
|
|
271
|
+
payload: dict[str, Any],
|
|
272
|
+
join_ref: str | None = None,
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
"""Send a raw Phoenix Channel message and wait for the reply.
|
|
275
|
+
|
|
276
|
+
Wire format: ``[join_ref, ref, topic, event, payload]``
|
|
277
|
+
"""
|
|
278
|
+
if not self._ws:
|
|
279
|
+
raise RuntimeError("WebSocket not connected")
|
|
280
|
+
|
|
281
|
+
ref = self._next_ref()
|
|
282
|
+
msg = json.dumps([join_ref, ref, topic, event, payload])
|
|
283
|
+
|
|
284
|
+
# Create future for reply
|
|
285
|
+
loop = asyncio.get_running_loop()
|
|
286
|
+
future: asyncio.Future[dict[str, Any]] = loop.create_future()
|
|
287
|
+
self._pending[ref] = future
|
|
288
|
+
|
|
289
|
+
await self._ws.send(msg)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
return await asyncio.wait_for(future, timeout=self._config.timeout)
|
|
293
|
+
except asyncio.TimeoutError:
|
|
294
|
+
self._pending.pop(ref, None)
|
|
295
|
+
raise TimeoutError(f"Push '{event}' timed out after {self._config.timeout}s")
|
|
296
|
+
|
|
297
|
+
async def _receive_loop(self) -> None:
|
|
298
|
+
"""Background loop reading messages from the WebSocket."""
|
|
299
|
+
try:
|
|
300
|
+
async for raw in self._ws: # type: ignore[union-attr]
|
|
301
|
+
try:
|
|
302
|
+
msg = json.loads(raw)
|
|
303
|
+
except json.JSONDecodeError:
|
|
304
|
+
logger.warning("Received non-JSON message: %s", raw[:200])
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
if not isinstance(msg, list) or len(msg) < 5:
|
|
308
|
+
logger.warning("Unexpected message format: %s", raw[:200])
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
_join_ref, ref, topic, event, payload = msg
|
|
312
|
+
|
|
313
|
+
# Handle replies to our pushes
|
|
314
|
+
if event == "phx_reply" and ref and ref in self._pending:
|
|
315
|
+
future = self._pending.pop(ref)
|
|
316
|
+
if not future.done():
|
|
317
|
+
future.set_result(payload)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# Handle server-pushed events (broadcasts)
|
|
321
|
+
if event == "phx_error":
|
|
322
|
+
self._emit_sync("error", RuntimeError(f"Channel error: {payload}"))
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
if event == "phx_close":
|
|
326
|
+
self._connected = False
|
|
327
|
+
self._emit_sync("disconnected", "server_close")
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
# Forward all other events to handlers
|
|
331
|
+
event_payload = payload.get("payload", payload) if isinstance(payload, dict) else payload
|
|
332
|
+
self._emit_sync(event, event_payload)
|
|
333
|
+
|
|
334
|
+
except websockets.exceptions.ConnectionClosed as exc:
|
|
335
|
+
self._connected = False
|
|
336
|
+
self._emit_sync("disconnected", f"connection_closed: {exc}")
|
|
337
|
+
except asyncio.CancelledError:
|
|
338
|
+
return
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
self._connected = False
|
|
341
|
+
self._emit_sync("error", exc)
|
|
342
|
+
self._emit_sync("disconnected", f"error: {exc}")
|
|
343
|
+
|
|
344
|
+
# Auto-reconnect
|
|
345
|
+
if self._running and self._config.auto_reconnect:
|
|
346
|
+
asyncio.ensure_future(self._reconnect())
|
|
347
|
+
|
|
348
|
+
async def _heartbeat_loop(self) -> None:
|
|
349
|
+
"""Send Phoenix heartbeat every 30 seconds."""
|
|
350
|
+
try:
|
|
351
|
+
while self._connected and self._ws:
|
|
352
|
+
await asyncio.sleep(30)
|
|
353
|
+
if self._ws and self._connected:
|
|
354
|
+
ref = self._next_ref()
|
|
355
|
+
msg = json.dumps([None, ref, "phoenix", "heartbeat", {}])
|
|
356
|
+
try:
|
|
357
|
+
await self._ws.send(msg)
|
|
358
|
+
except Exception:
|
|
359
|
+
break
|
|
360
|
+
except asyncio.CancelledError:
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
async def _reconnect(self) -> None:
|
|
364
|
+
"""Attempt to reconnect with exponential backoff."""
|
|
365
|
+
while (
|
|
366
|
+
self._running
|
|
367
|
+
and self._reconnect_attempts < self._config.max_reconnect_attempts
|
|
368
|
+
):
|
|
369
|
+
self._reconnect_attempts += 1
|
|
370
|
+
delay = min(
|
|
371
|
+
self._config.reconnect_interval * (1.5 ** self._reconnect_attempts),
|
|
372
|
+
30.0,
|
|
373
|
+
)
|
|
374
|
+
self._emit_sync("reconnecting", self._reconnect_attempts)
|
|
375
|
+
logger.info(
|
|
376
|
+
"Reconnecting (attempt %d/%d) in %.1fs...",
|
|
377
|
+
self._reconnect_attempts,
|
|
378
|
+
self._config.max_reconnect_attempts,
|
|
379
|
+
delay,
|
|
380
|
+
)
|
|
381
|
+
await asyncio.sleep(delay)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
await self._do_connect()
|
|
385
|
+
logger.info("Reconnected successfully")
|
|
386
|
+
return
|
|
387
|
+
except Exception as exc:
|
|
388
|
+
logger.warning("Reconnect attempt %d failed: %s", self._reconnect_attempts, exc)
|
|
389
|
+
|
|
390
|
+
if self._running:
|
|
391
|
+
self._emit_sync(
|
|
392
|
+
"error",
|
|
393
|
+
RuntimeError(
|
|
394
|
+
f"Failed to reconnect after {self._config.max_reconnect_attempts} attempts"
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# ── Cleanup ───────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
async def __aenter__(self) -> "RingforgeClient":
|
|
401
|
+
await self.connect()
|
|
402
|
+
return self
|
|
403
|
+
|
|
404
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
405
|
+
await self.disconnect()
|
ringforge/groups.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Groups sub-API for agent collaboration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ringforge.types import CreateGroupParams, Group
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ringforge.client import RingforgeClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GroupAPI:
|
|
15
|
+
"""Create and manage agent groups for collaboration."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: RingforgeClient) -> None:
|
|
18
|
+
self._client = client
|
|
19
|
+
|
|
20
|
+
async def create(self, params: CreateGroupParams) -> Group:
|
|
21
|
+
"""Create a new group."""
|
|
22
|
+
payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
|
|
23
|
+
resp = await self._client.push("group:create", {"payload": payload})
|
|
24
|
+
return Group(**resp)
|
|
25
|
+
|
|
26
|
+
async def join(self, group_id: str) -> None:
|
|
27
|
+
"""Join an existing group."""
|
|
28
|
+
await self._client.push("group:join", {"payload": {"group_id": group_id}})
|
|
29
|
+
|
|
30
|
+
async def leave(self, group_id: str) -> None:
|
|
31
|
+
"""Leave a group."""
|
|
32
|
+
await self._client.push("group:leave", {"payload": {"group_id": group_id}})
|
|
33
|
+
|
|
34
|
+
async def message(self, group_id: str, message: Any) -> None:
|
|
35
|
+
"""Send a message to a group."""
|
|
36
|
+
await self._client.push(
|
|
37
|
+
"group:message", {"payload": {"group_id": group_id, "message": message}}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def list(
|
|
41
|
+
self, *, status: str | None = None, type: str | None = None
|
|
42
|
+
) -> list[Group]:
|
|
43
|
+
"""List groups in the fleet."""
|
|
44
|
+
opts: dict[str, Any] = {}
|
|
45
|
+
if status is not None:
|
|
46
|
+
opts["status"] = status
|
|
47
|
+
if type is not None:
|
|
48
|
+
opts["type"] = type
|
|
49
|
+
resp = await self._client.push("group:list", {"payload": opts})
|
|
50
|
+
return [Group(**g) for g in resp.get("groups", [])]
|
|
51
|
+
|
|
52
|
+
async def my_groups(self) -> list[Group]:
|
|
53
|
+
"""Get groups the current agent belongs to."""
|
|
54
|
+
resp = await self._client.push("group:my_groups", {})
|
|
55
|
+
return [Group(**g) for g in resp.get("groups", [])]
|
|
56
|
+
|
|
57
|
+
async def dissolve(self, group_id: str, result: Any = None) -> None:
|
|
58
|
+
"""Dissolve a group (owner only)."""
|
|
59
|
+
await self._client.push(
|
|
60
|
+
"group:dissolve", {"payload": {"group_id": group_id, "result": result}}
|
|
61
|
+
)
|
ringforge/memory.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Memory sub-API for shared key-value storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ringforge.types import MemoryEntry, QueryMemoryParams, SetMemoryParams
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ringforge.client import RingforgeClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MemoryAPI:
|
|
15
|
+
"""Shared key-value store accessible by all agents in the fleet."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: RingforgeClient) -> None:
|
|
18
|
+
self._client = client
|
|
19
|
+
|
|
20
|
+
async def set(
|
|
21
|
+
self,
|
|
22
|
+
key: str,
|
|
23
|
+
params: SetMemoryParams,
|
|
24
|
+
idempotency_key: str | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Set a memory entry."""
|
|
27
|
+
payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
|
|
28
|
+
payload["key"] = key
|
|
29
|
+
return await self._client.push(
|
|
30
|
+
"memory:set", {"payload": payload}, idempotency_key=idempotency_key
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
async def get(self, key: str) -> MemoryEntry:
|
|
34
|
+
"""Get a memory entry by key."""
|
|
35
|
+
resp = await self._client.push("memory:get", {"payload": {"key": key}})
|
|
36
|
+
return MemoryEntry(**resp.get("payload", {}))
|
|
37
|
+
|
|
38
|
+
async def delete(self, key: str) -> None:
|
|
39
|
+
"""Delete a memory entry."""
|
|
40
|
+
await self._client.push("memory:delete", {"payload": {"key": key}})
|
|
41
|
+
|
|
42
|
+
async def list(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
limit: int | None = None,
|
|
46
|
+
offset: int | None = None,
|
|
47
|
+
tags: list[str] | None = None,
|
|
48
|
+
author: str | None = None,
|
|
49
|
+
) -> list[MemoryEntry]:
|
|
50
|
+
"""List memory entries."""
|
|
51
|
+
opts: dict[str, Any] = {}
|
|
52
|
+
if limit is not None:
|
|
53
|
+
opts["limit"] = limit
|
|
54
|
+
if offset is not None:
|
|
55
|
+
opts["offset"] = offset
|
|
56
|
+
if tags is not None:
|
|
57
|
+
opts["tags"] = tags
|
|
58
|
+
if author is not None:
|
|
59
|
+
opts["author"] = author
|
|
60
|
+
resp = await self._client.push("memory:list", {"payload": opts})
|
|
61
|
+
entries_data = resp.get("payload", {}).get("entries", [])
|
|
62
|
+
return [MemoryEntry(**e) for e in entries_data]
|
|
63
|
+
|
|
64
|
+
async def query(self, params: QueryMemoryParams) -> list[MemoryEntry]:
|
|
65
|
+
"""Search/query memory entries."""
|
|
66
|
+
payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
|
|
67
|
+
resp = await self._client.push("memory:query", {"payload": payload})
|
|
68
|
+
entries_data = resp.get("payload", {}).get("entries", [])
|
|
69
|
+
return [MemoryEntry(**e) for e in entries_data]
|
|
70
|
+
|
|
71
|
+
async def subscribe(
|
|
72
|
+
self, pattern: str, events: list[str] | None = None
|
|
73
|
+
) -> dict[str, str]:
|
|
74
|
+
"""Subscribe to memory changes by pattern."""
|
|
75
|
+
return await self._client.push(
|
|
76
|
+
"memory:subscribe", {"payload": {"pattern": pattern, "events": events}}
|
|
77
|
+
)
|
ringforge/messaging.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Messaging sub-API for direct messages between agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ringforge.types import DirectMessage, SendMessageParams
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ringforge.client import RingforgeClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DirectMessageAPI:
|
|
15
|
+
"""Send and receive direct messages between agents."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: RingforgeClient) -> None:
|
|
18
|
+
self._client = client
|
|
19
|
+
|
|
20
|
+
async def send(
|
|
21
|
+
self,
|
|
22
|
+
params: SendMessageParams,
|
|
23
|
+
idempotency_key: str | None = None,
|
|
24
|
+
) -> dict[str, str]:
|
|
25
|
+
"""Send a direct message to another agent."""
|
|
26
|
+
payload = {k: v for k, v in asdict(params).items() if v is not None}
|
|
27
|
+
resp = await self._client.push(
|
|
28
|
+
"direct:send", {"payload": payload}, idempotency_key=idempotency_key
|
|
29
|
+
)
|
|
30
|
+
return resp.get("payload", resp)
|
|
31
|
+
|
|
32
|
+
async def history(self, with_agent: str, limit: int = 50) -> list[DirectMessage]:
|
|
33
|
+
"""Get conversation history with another agent."""
|
|
34
|
+
resp = await self._client.push(
|
|
35
|
+
"direct:history", {"payload": {"with": with_agent, "limit": limit}}
|
|
36
|
+
)
|
|
37
|
+
messages_data = resp.get("payload", {}).get("messages", [])
|
|
38
|
+
return [DirectMessage(**m) for m in messages_data]
|
ringforge/presence.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Presence sub-API for tracking agent state across the fleet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from ringforge.types import AgentState, PresenceAgent
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ringforge.client import RingforgeClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PresenceAPI:
|
|
14
|
+
"""Manage agent presence within the fleet."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client: RingforgeClient) -> None:
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
async def update(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
state: AgentState | None = None,
|
|
23
|
+
task: str | None = None,
|
|
24
|
+
load: float | None = None,
|
|
25
|
+
metadata: dict[str, Any] | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Update this agent's presence."""
|
|
28
|
+
params: dict[str, Any] = {}
|
|
29
|
+
if state is not None:
|
|
30
|
+
params["state"] = state
|
|
31
|
+
if task is not None:
|
|
32
|
+
params["task"] = task
|
|
33
|
+
if load is not None:
|
|
34
|
+
params["load"] = load
|
|
35
|
+
if metadata is not None:
|
|
36
|
+
params["metadata"] = metadata
|
|
37
|
+
await self._client.push("presence:update", {"payload": params})
|
|
38
|
+
|
|
39
|
+
async def roster(self) -> list[PresenceAgent]:
|
|
40
|
+
"""Request the current fleet roster."""
|
|
41
|
+
resp = await self._client.push("presence:roster", {})
|
|
42
|
+
agents_data = resp.get("payload", {}).get("agents", [])
|
|
43
|
+
return [PresenceAgent(**a) for a in agents_data]
|
ringforge/tasks.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Tasks sub-API for orchestrating work across agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ringforge.types import TaskEvent, TaskResult, TaskSubmitParams
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ringforge.client import RingforgeClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TasksAPI:
|
|
15
|
+
"""Orchestrate work across agents with the task system."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: RingforgeClient) -> None:
|
|
18
|
+
self._client = client
|
|
19
|
+
|
|
20
|
+
async def submit(
|
|
21
|
+
self,
|
|
22
|
+
params: TaskSubmitParams,
|
|
23
|
+
idempotency_key: str | None = None,
|
|
24
|
+
) -> dict[str, str]:
|
|
25
|
+
"""Submit a new task for assignment.
|
|
26
|
+
|
|
27
|
+
Returns dict with ``task_id`` and ``status``.
|
|
28
|
+
"""
|
|
29
|
+
payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
|
|
30
|
+
return await self._client.push(
|
|
31
|
+
"task:submit", {"payload": payload}, idempotency_key=idempotency_key
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def status(self, task_id: str) -> TaskEvent:
|
|
35
|
+
"""Get the status of a task."""
|
|
36
|
+
resp = await self._client.push(
|
|
37
|
+
"task:status", {"payload": {"task_id": task_id}}
|
|
38
|
+
)
|
|
39
|
+
return _parse_task_event(resp.get("payload", resp))
|
|
40
|
+
|
|
41
|
+
async def claim(self, task_id: str) -> TaskEvent:
|
|
42
|
+
"""Claim an assigned task (worker agents)."""
|
|
43
|
+
resp = await self._client.push(
|
|
44
|
+
"task:claim", {"payload": {"task_id": task_id}}
|
|
45
|
+
)
|
|
46
|
+
return _parse_task_event(resp.get("payload", resp))
|
|
47
|
+
|
|
48
|
+
async def result(self, task_id: str, result: TaskResult) -> TaskEvent:
|
|
49
|
+
"""Submit a task result (worker agents)."""
|
|
50
|
+
resp = await self._client.push(
|
|
51
|
+
"task:result",
|
|
52
|
+
{"payload": {"task_id": task_id, "result": asdict(result)}},
|
|
53
|
+
)
|
|
54
|
+
return _parse_task_event(resp.get("payload", resp))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_task_event(data: dict[str, Any]) -> TaskEvent:
|
|
58
|
+
"""Parse a raw dict into a TaskEvent, handling nested TaskResult."""
|
|
59
|
+
result_data = data.pop("result", None)
|
|
60
|
+
task = TaskEvent(**{k: v for k, v in data.items() if k in TaskEvent.__dataclass_fields__})
|
|
61
|
+
if result_data and isinstance(result_data, dict):
|
|
62
|
+
task.result = TaskResult(**{k: v for k, v in result_data.items() if k in TaskResult.__dataclass_fields__})
|
|
63
|
+
return task
|
ringforge/types.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Type definitions for the Ringforge SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ── Agent State ────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
AgentState = Literal["online", "busy", "away", "offline"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── Config ─────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AgentConfig:
|
|
20
|
+
"""Agent identity configuration."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
framework: str = "ringforge-sdk-py"
|
|
24
|
+
capabilities: list[str] = field(default_factory=list)
|
|
25
|
+
state: AgentState = "online"
|
|
26
|
+
task: str = ""
|
|
27
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RingforgeConfig:
|
|
32
|
+
"""Client connection configuration."""
|
|
33
|
+
|
|
34
|
+
server: str
|
|
35
|
+
api_key: str
|
|
36
|
+
agent: AgentConfig
|
|
37
|
+
fleet_id: str | None = None
|
|
38
|
+
auto_reconnect: bool = True
|
|
39
|
+
reconnect_interval: float = 3.0
|
|
40
|
+
max_reconnect_attempts: int = 10
|
|
41
|
+
timeout: float = 10.0
|
|
42
|
+
private_key: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Presence ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PresenceAgent:
|
|
50
|
+
"""An agent visible in the fleet roster."""
|
|
51
|
+
|
|
52
|
+
agent_id: str
|
|
53
|
+
name: str | None = None
|
|
54
|
+
framework: str | None = None
|
|
55
|
+
capabilities: list[str] = field(default_factory=list)
|
|
56
|
+
state: AgentState = "online"
|
|
57
|
+
task: str | None = None
|
|
58
|
+
load: float = 0.0
|
|
59
|
+
connected_at: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Activity ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
ActivityKind = Literal[
|
|
65
|
+
"task_started",
|
|
66
|
+
"task_progress",
|
|
67
|
+
"task_completed",
|
|
68
|
+
"task_failed",
|
|
69
|
+
"discovery",
|
|
70
|
+
"question",
|
|
71
|
+
"alert",
|
|
72
|
+
"custom",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ActivityEvent:
|
|
78
|
+
"""A fleet activity event."""
|
|
79
|
+
|
|
80
|
+
event_id: str
|
|
81
|
+
from_agent: dict[str, str] = field(default_factory=dict) # {agent_id, name}
|
|
82
|
+
kind: str = "custom"
|
|
83
|
+
description: str = ""
|
|
84
|
+
tags: list[str] = field(default_factory=list)
|
|
85
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
86
|
+
timestamp: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class BroadcastActivityParams:
|
|
91
|
+
"""Parameters for broadcasting an activity event."""
|
|
92
|
+
|
|
93
|
+
kind: str # ActivityKind
|
|
94
|
+
description: str
|
|
95
|
+
tags: list[str] = field(default_factory=list)
|
|
96
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
97
|
+
scope: Literal["fleet", "tagged", "direct"] = "fleet"
|
|
98
|
+
to: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Memory ─────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class MemoryEntry:
|
|
106
|
+
"""A shared memory entry."""
|
|
107
|
+
|
|
108
|
+
id: str = ""
|
|
109
|
+
key: str = ""
|
|
110
|
+
fleet_id: str = ""
|
|
111
|
+
value: str = ""
|
|
112
|
+
type: str = "string"
|
|
113
|
+
tags: list[str] = field(default_factory=list)
|
|
114
|
+
author: str | None = None
|
|
115
|
+
created_at: str = ""
|
|
116
|
+
updated_at: str = ""
|
|
117
|
+
ttl: str | None = None
|
|
118
|
+
access_count: int = 0
|
|
119
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class SetMemoryParams:
|
|
124
|
+
"""Parameters for setting a memory entry."""
|
|
125
|
+
|
|
126
|
+
value: str
|
|
127
|
+
tags: list[str] = field(default_factory=list)
|
|
128
|
+
type: str = "string"
|
|
129
|
+
ttl: int | None = None
|
|
130
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class QueryMemoryParams:
|
|
135
|
+
"""Parameters for querying memory entries."""
|
|
136
|
+
|
|
137
|
+
tags: list[str] = field(default_factory=list)
|
|
138
|
+
text_search: str | None = None
|
|
139
|
+
author: str | None = None
|
|
140
|
+
since: str | None = None
|
|
141
|
+
limit: int | None = None
|
|
142
|
+
sort: Literal["relevance", "created_at", "updated_at", "access_count"] | None = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── Direct Messages ────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class DirectMessage:
|
|
150
|
+
"""A direct message between agents."""
|
|
151
|
+
|
|
152
|
+
message_id: str = ""
|
|
153
|
+
from_agent: dict[str, str] = field(default_factory=dict)
|
|
154
|
+
to: str = ""
|
|
155
|
+
correlation_id: str | None = None
|
|
156
|
+
message: Any = None
|
|
157
|
+
timestamp: str = ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class SendMessageParams:
|
|
162
|
+
"""Parameters for sending a direct message."""
|
|
163
|
+
|
|
164
|
+
to: str
|
|
165
|
+
message: Any
|
|
166
|
+
correlation_id: str | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Groups ─────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class Group:
|
|
174
|
+
"""An agent group."""
|
|
175
|
+
|
|
176
|
+
group_id: str = ""
|
|
177
|
+
name: str = ""
|
|
178
|
+
type: str = "squad"
|
|
179
|
+
capabilities: list[str] = field(default_factory=list)
|
|
180
|
+
status: str = ""
|
|
181
|
+
created_by: str = ""
|
|
182
|
+
member_count: int = 0
|
|
183
|
+
settings: dict[str, Any] = field(default_factory=dict)
|
|
184
|
+
inserted_at: str = ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class CreateGroupParams:
|
|
189
|
+
"""Parameters for creating a group."""
|
|
190
|
+
|
|
191
|
+
name: str
|
|
192
|
+
type: Literal["squad", "pod", "channel"] = "squad"
|
|
193
|
+
capabilities: list[str] = field(default_factory=list)
|
|
194
|
+
settings: dict[str, Any] = field(default_factory=dict)
|
|
195
|
+
invite: list[str] = field(default_factory=list)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── Tasks ──────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
TaskStatus = Literal["pending", "assigned", "claimed", "running", "completed", "failed", "timeout"]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class TaskSubmitParams:
|
|
205
|
+
"""Parameters for submitting a task."""
|
|
206
|
+
|
|
207
|
+
name: str
|
|
208
|
+
description: str
|
|
209
|
+
capabilities: list[str] = field(default_factory=list)
|
|
210
|
+
assign_to: str | None = None
|
|
211
|
+
priority: int = 0
|
|
212
|
+
timeout: int = 300
|
|
213
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
214
|
+
tags: list[str] = field(default_factory=list)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class TaskResult:
|
|
219
|
+
"""Result of a completed task."""
|
|
220
|
+
|
|
221
|
+
success: bool
|
|
222
|
+
data: Any = None
|
|
223
|
+
error: str | None = None
|
|
224
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass
|
|
228
|
+
class TaskEvent:
|
|
229
|
+
"""A task lifecycle event."""
|
|
230
|
+
|
|
231
|
+
task_id: str = ""
|
|
232
|
+
name: str = ""
|
|
233
|
+
status: str = "pending" # TaskStatus
|
|
234
|
+
submitted_by: str = ""
|
|
235
|
+
assigned_to: str | None = None
|
|
236
|
+
claimed_by: str | None = None
|
|
237
|
+
priority: int = 0
|
|
238
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
239
|
+
result: TaskResult | None = None
|
|
240
|
+
tags: list[str] = field(default_factory=list)
|
|
241
|
+
timeout: int = 300
|
|
242
|
+
created_at: str = ""
|
|
243
|
+
updated_at: str = ""
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ringforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Ringforge agent mesh platform
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: agent,ai,mesh,ringforge,sdk
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: pynacl>=1.5.0
|
|
9
|
+
Requires-Dist: websockets>=12.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# ringforge
|
|
16
|
+
|
|
17
|
+
Python SDK for the **Ringforge** agent mesh platform. Connect AI agents into a real-time fleet with presence tracking, shared memory, task orchestration, direct messaging, and group collaboration — all async over WebSocket.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install ringforge
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
from ringforge import RingforgeClient, RingforgeConfig, AgentConfig
|
|
30
|
+
|
|
31
|
+
async def main():
|
|
32
|
+
config = RingforgeConfig(
|
|
33
|
+
server="wss://ringforge.example.com/ws/websocket",
|
|
34
|
+
api_key="rf_live_...",
|
|
35
|
+
agent=AgentConfig(name="my-agent", capabilities=["search", "summarize"]),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async with RingforgeClient(config) as client:
|
|
39
|
+
print(f"Connected as {client.agent_id}")
|
|
40
|
+
|
|
41
|
+
client.on("direct:message", lambda msg: print(f"Got: {msg}"))
|
|
42
|
+
|
|
43
|
+
# Keep running
|
|
44
|
+
await asyncio.Event().wait()
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from ringforge import RingforgeConfig, AgentConfig
|
|
53
|
+
|
|
54
|
+
config = RingforgeConfig(
|
|
55
|
+
server="wss://ringforge.example.com/ws/websocket",
|
|
56
|
+
api_key="rf_live_...",
|
|
57
|
+
agent=AgentConfig(
|
|
58
|
+
name="my-agent",
|
|
59
|
+
framework="langchain", # optional
|
|
60
|
+
capabilities=["search"], # optional
|
|
61
|
+
state="online", # initial state
|
|
62
|
+
task="Waiting for work", # initial task description
|
|
63
|
+
metadata={"version": "1.0"}, # arbitrary metadata
|
|
64
|
+
),
|
|
65
|
+
fleet_id="fleet_abc123", # optional — omit to auto-resolve
|
|
66
|
+
auto_reconnect=True, # default: True
|
|
67
|
+
reconnect_interval=3.0, # default: 3.0s
|
|
68
|
+
max_reconnect_attempts=10, # default: 10
|
|
69
|
+
timeout=10.0, # push timeout, default: 10s
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Sub-APIs
|
|
74
|
+
|
|
75
|
+
### Presence
|
|
76
|
+
|
|
77
|
+
Track agent state across the fleet.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# Update your presence
|
|
81
|
+
await client.presence.update(state="busy", task="Processing request #42")
|
|
82
|
+
|
|
83
|
+
# Get the full fleet roster
|
|
84
|
+
agents = await client.presence.roster()
|
|
85
|
+
for agent in agents:
|
|
86
|
+
print(f"{agent.name} [{agent.state}]")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Activity
|
|
90
|
+
|
|
91
|
+
Broadcast and subscribe to fleet-wide activity events.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from ringforge import BroadcastActivityParams
|
|
95
|
+
|
|
96
|
+
# Broadcast an activity event
|
|
97
|
+
await client.activity.broadcast(BroadcastActivityParams(
|
|
98
|
+
kind="task_completed",
|
|
99
|
+
description="Finished data analysis",
|
|
100
|
+
tags=["analytics"],
|
|
101
|
+
data={"rows_processed": 50_000},
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
# Get recent history
|
|
105
|
+
events = await client.activity.history(limit=20)
|
|
106
|
+
|
|
107
|
+
# Subscribe to tags
|
|
108
|
+
await client.activity.subscribe(["alerts", "deployments"])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Memory
|
|
112
|
+
|
|
113
|
+
Shared key-value store accessible by all agents.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from ringforge import SetMemoryParams, QueryMemoryParams
|
|
117
|
+
|
|
118
|
+
# Set a value
|
|
119
|
+
await client.memory.set("config:model", SetMemoryParams(
|
|
120
|
+
value="gpt-4o",
|
|
121
|
+
tags=["config"],
|
|
122
|
+
ttl=3600,
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
# Get a value
|
|
126
|
+
entry = await client.memory.get("config:model")
|
|
127
|
+
print(entry.value) # "gpt-4o"
|
|
128
|
+
|
|
129
|
+
# Search memory
|
|
130
|
+
results = await client.memory.query(QueryMemoryParams(
|
|
131
|
+
tags=["config"],
|
|
132
|
+
text_search="model",
|
|
133
|
+
sort="relevance",
|
|
134
|
+
))
|
|
135
|
+
|
|
136
|
+
# Subscribe to changes
|
|
137
|
+
await client.memory.subscribe("config:*")
|
|
138
|
+
client.on("memory:changed", lambda e: print(f"{e['key']} changed"))
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Direct Messages
|
|
142
|
+
|
|
143
|
+
Send messages to specific agents.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from ringforge import SendMessageParams
|
|
147
|
+
|
|
148
|
+
# Send a message
|
|
149
|
+
await client.dm.send(SendMessageParams(
|
|
150
|
+
to="agent_abc123",
|
|
151
|
+
message={"type": "question", "text": "What is the current status?"},
|
|
152
|
+
correlation_id="req-001",
|
|
153
|
+
))
|
|
154
|
+
|
|
155
|
+
# Listen for incoming messages
|
|
156
|
+
client.on("direct:message", lambda msg: print(f"From {msg['from']['name']}: {msg['message']}"))
|
|
157
|
+
|
|
158
|
+
# Get conversation history
|
|
159
|
+
history = await client.dm.history("agent_abc123", limit=25)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Groups
|
|
163
|
+
|
|
164
|
+
Create and manage agent groups.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from ringforge import CreateGroupParams
|
|
168
|
+
|
|
169
|
+
# Create a group
|
|
170
|
+
group = await client.groups.create(CreateGroupParams(
|
|
171
|
+
name="research-squad",
|
|
172
|
+
type="squad",
|
|
173
|
+
capabilities=["search", "summarize"],
|
|
174
|
+
invite=["agent_abc123"],
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
# Send a group message
|
|
178
|
+
await client.groups.message(group.group_id, {"status": "in_progress"})
|
|
179
|
+
|
|
180
|
+
# Listen for group messages
|
|
181
|
+
client.on("group:message", lambda msg: print(f"[{msg['group_id']}] {msg}"))
|
|
182
|
+
|
|
183
|
+
# List groups
|
|
184
|
+
groups = await client.groups.list()
|
|
185
|
+
my_groups = await client.groups.my_groups()
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Tasks
|
|
189
|
+
|
|
190
|
+
Orchestrate work across agents.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from ringforge import TaskSubmitParams, TaskResult
|
|
194
|
+
|
|
195
|
+
# ── Submitter: create a task ──
|
|
196
|
+
result = await client.tasks.submit(TaskSubmitParams(
|
|
197
|
+
name="analyze-dataset",
|
|
198
|
+
description="Run sentiment analysis on Q4 reviews",
|
|
199
|
+
capabilities=["nlp", "sentiment"],
|
|
200
|
+
priority=5,
|
|
201
|
+
timeout=300,
|
|
202
|
+
params={"dataset": "s3://bucket/reviews-q4.csv"},
|
|
203
|
+
tags=["analytics", "nlp"],
|
|
204
|
+
))
|
|
205
|
+
task_id = result["task_id"]
|
|
206
|
+
|
|
207
|
+
# Check task status
|
|
208
|
+
task = await client.tasks.status(task_id)
|
|
209
|
+
print(f"Task {task.name}: {task.status}")
|
|
210
|
+
|
|
211
|
+
# Listen for results
|
|
212
|
+
client.on("task:result", lambda e: print(f"Result: {e}"))
|
|
213
|
+
|
|
214
|
+
# ── Worker: claim and complete tasks ──
|
|
215
|
+
async def handle_task(event):
|
|
216
|
+
await client.tasks.claim(event["task_id"])
|
|
217
|
+
# ... do work ...
|
|
218
|
+
await client.tasks.result(event["task_id"], TaskResult(
|
|
219
|
+
success=True,
|
|
220
|
+
data={"sentiment": "positive", "confidence": 0.92},
|
|
221
|
+
metadata={"duration_ms": 4200},
|
|
222
|
+
))
|
|
223
|
+
|
|
224
|
+
client.on("task:assigned", handle_task)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Events
|
|
228
|
+
|
|
229
|
+
Subscribe to real-time events with `client.on(event, handler)`:
|
|
230
|
+
|
|
231
|
+
| Event | Payload | Description |
|
|
232
|
+
|-------|---------|-------------|
|
|
233
|
+
| `connected` | `agent_id: str` | Successfully joined the fleet |
|
|
234
|
+
| `disconnected` | `reason: str` | Disconnected from fleet |
|
|
235
|
+
| `reconnecting` | `attempt: int` | Attempting to reconnect |
|
|
236
|
+
| `error` | `exception` | Connection or protocol error |
|
|
237
|
+
| `presence:joined` | `PresenceAgent` | Agent joined the fleet |
|
|
238
|
+
| `presence:left` | `{agent_id}` | Agent left the fleet |
|
|
239
|
+
| `presence:state_changed` | `PresenceAgent` | Agent updated their state |
|
|
240
|
+
| `presence:roster` | `[PresenceAgent]` | Full roster response |
|
|
241
|
+
| `activity:broadcast` | `ActivityEvent` | Activity event received |
|
|
242
|
+
| `direct:message` | `DirectMessage` | Direct message received |
|
|
243
|
+
| `memory:changed` | `{key, action, author, timestamp}` | Memory entry changed |
|
|
244
|
+
| `group:created` | `Group` | Group created |
|
|
245
|
+
| `group:member_joined` | `{group_id, agent_id}` | Agent joined a group |
|
|
246
|
+
| `group:member_left` | `{group_id, agent_id}` | Agent left a group |
|
|
247
|
+
| `group:dissolved` | `{group_id, result, dissolved_by}` | Group dissolved |
|
|
248
|
+
| `group:message` | `{group_id, from, message, timestamp}` | Group message received |
|
|
249
|
+
| `group:invite` | `{group_id, name, type, invited_by}` | Invited to a group |
|
|
250
|
+
| `task:assigned` | `TaskEvent` | Task assigned to you |
|
|
251
|
+
| `task:claimed` | `TaskEvent` | Task was claimed |
|
|
252
|
+
| `task:result` | `TaskEvent` | Task result submitted |
|
|
253
|
+
| `task:timeout` | `TaskEvent` | Task timed out |
|
|
254
|
+
| `system:quota_warning` | `{resource, used, limit}` | Approaching resource limit |
|
|
255
|
+
|
|
256
|
+
Handlers can be sync functions or async coroutines — both work.
|
|
257
|
+
|
|
258
|
+
## Idempotency
|
|
259
|
+
|
|
260
|
+
All mutating operations accept an optional `idempotency_key`. Duplicate pushes with the same key within 5 minutes return the cached result.
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
import uuid
|
|
264
|
+
|
|
265
|
+
key = str(uuid.uuid4())
|
|
266
|
+
await client.activity.broadcast(params, idempotency_key=key)
|
|
267
|
+
# Safe to retry
|
|
268
|
+
await client.activity.broadcast(params, idempotency_key=key)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Features
|
|
272
|
+
|
|
273
|
+
- **Async/await** — built on `asyncio` and `websockets`
|
|
274
|
+
- **Auto-reconnect** — exponential backoff, configurable attempts
|
|
275
|
+
- **Type hints everywhere** — dataclasses, not dicts
|
|
276
|
+
- **Phoenix Channel v2** — native wire protocol implementation
|
|
277
|
+
- **Event-driven** — sync and async handlers
|
|
278
|
+
- **Idempotency** — built-in client-side dedup cache
|
|
279
|
+
- **Context manager** — `async with RingforgeClient(config) as client:`
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
See [LICENSE](../../LICENSE) in the repository root.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
ringforge/__init__.py,sha256=IoktcvqZcEY_oznRqB_yDbd1zmsy9p0s8ZaM-R6VRzA,942
|
|
2
|
+
ringforge/activity.py,sha256=PzQdHevl2fERmH0XOVCxrFAywq7tIj-DoWr8nYEl30Q,2056
|
|
3
|
+
ringforge/client.py,sha256=bv74NDGRCus7kXZf_f-5zQeLXXwDtgHlPnb1KtzuDB4,14289
|
|
4
|
+
ringforge/groups.py,sha256=FSm4zhVOWZYUpJnV_b-JPlZbIzdMSocWYPqTMqU4NCI,2220
|
|
5
|
+
ringforge/memory.py,sha256=VpEf_CVRCRvZb_ETcaSZXpeKVWJTxd7lNDitFpgUjkE,2749
|
|
6
|
+
ringforge/messaging.py,sha256=1u5JaSOwuJd3L2RM_uAopDv3Idv8fb9uToSnOd9hGRA,1314
|
|
7
|
+
ringforge/presence.py,sha256=FJbArqGg0kY5xsD7Ias39kEDxma7MwgEVVqSdjnSqPg,1345
|
|
8
|
+
ringforge/tasks.py,sha256=-GfFFZxnPp6WyOxmx5Kgy0iD0A2Kn5mA1CtMT3EBqJA,2282
|
|
9
|
+
ringforge/types.py,sha256=5ra2urVNLfYb1tmHljfexKOp0DgA-j6bpnq1IHDdRk0,6593
|
|
10
|
+
ringforge-0.1.0.dist-info/METADATA,sha256=5uNkrX6bKucLAg-gIXPFrFvDf8btCl7ZyS6hvD0nvJc,8254
|
|
11
|
+
ringforge-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
ringforge-0.1.0.dist-info/RECORD,,
|