Skip to content

Commit b9aaa8a

Browse files
authored
Merge pull request #47 from Agentiix/codex/rpc-namespace
Use /rpc namespace for remote calls
2 parents 2a2c134 + 57fcc75 commit b9aaa8a

8 files changed

Lines changed: 90 additions & 59 deletions

File tree

agentix/runtime/PROTOCOL.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,19 @@ await client.remote(run, seed=42)
3535
| Path | Carries | Wire |
3636
| --- | --- | --- |
3737
| `GET /health` | health probe | HTTP JSON |
38-
| Socket.IO `/` | `c.remote()` RPC | msgpack-wrapped `call` / `call:result` / `call:error` / `cancel` |
38+
| `POST /call` | internal short-call fast path | HTTP msgpack |
39+
| Socket.IO `/rpc` | `c.remote()` RPC | msgpack-wrapped `call` / `call:result` / `call:error` / `cancel` |
3940
| Socket.IO `/trace`, `/log`, `/<plugin>` | side channels | plugin-defined events (msgpack payloads) |
4041
| worker private pipe | runtime ↔ worker | length-prefixed msgpack frames |
4142

42-
HTTP is only for `/health`. Socket.IO is the host-to-runtime edge.
43-
The worker pipe is the runtime-to-worker edge inside the sandbox.
44-
The current implementation uses one worker subprocess per runtime.
43+
HTTP covers health plus the internal `/call` fast path for short
44+
remote calls. Socket.IO `/rpc` remains the RPC event channel when a
45+
call is submitted over SIO or an accepted HTTP call completes
46+
asynchronously. The worker pipe is the runtime-to-worker edge inside
47+
the sandbox. The current implementation uses one worker subprocess per
48+
runtime.
4549

46-
## Socket.IO Events (RPC on `/`)
50+
## Socket.IO Events (RPC on `/rpc`)
4751

4852
```text
4953
call {call_id, callable, arguments}

agentix/runtime/client/client.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
await c.remote(abridge.start_service, ...)
1616
1717
Core auto-registers `/trace` and `/log` namespaces so trace + log
18-
records flow from the sandbox without setup. `/` carries RPC.
18+
records flow from the sandbox without setup. `/rpc` carries RPC.
1919
"""
2020

2121
from __future__ import annotations
@@ -40,6 +40,7 @@
4040

4141
P = ParamSpec("P")
4242
R = TypeVar("R")
43+
RPC_NAMESPACE = "/rpc"
4344

4445

4546
class RemoteCallError(RuntimeError):
@@ -198,7 +199,7 @@ async def remote(
198199
# and completes via the normal SIO result channel.
199200
kind, value = await self._try_http_fast_path(sio=sio, payload=payload)
200201
if kind == "fallback":
201-
await sio.emit("call", pack(payload))
202+
await sio.emit("call", pack(payload), namespace=RPC_NAMESPACE)
202203
elif kind == "result":
203204
terminated = True
204205
return cast(R, value)
@@ -218,7 +219,11 @@ async def remote(
218219
self._pending.pop(call_id, None)
219220
if not terminated:
220221
with contextlib.suppress(BaseException):
221-
await sio.emit("cancel", pack({"call_id": call_id}))
222+
await sio.emit(
223+
"cancel",
224+
pack({"call_id": call_id}),
225+
namespace=RPC_NAMESPACE,
226+
)
222227

223228
# ── Socket.IO connection management ─────────────────────────
224229

@@ -242,8 +247,8 @@ async def _on_call_result(data):
242247
async def _on_call_error(data):
243248
await self._route_event("error", data)
244249

245-
sio.on("call:result", _on_call_result)
246-
sio.on("call:error", _on_call_error)
250+
sio.on("call:result", _on_call_result, namespace=RPC_NAMESPACE)
251+
sio.on("call:error", _on_call_error, namespace=RPC_NAMESPACE)
247252

248253
async def _on_connect(*_args):
249254
# Fires on initial connect and on every reconnect. Tell
@@ -253,18 +258,22 @@ async def _on_connect(*_args):
253258
if not pending_ids:
254259
return
255260
with contextlib.suppress(BaseException):
256-
await sio.emit("resume", pack({"call_ids": pending_ids}))
261+
await sio.emit(
262+
"resume",
263+
pack({"call_ids": pending_ids}),
264+
namespace=RPC_NAMESPACE,
265+
)
257266

258267
async def _on_disconnect(*_args):
259268
# Tasks survive on the server side; results are buffered
260269
# until ack. We rely on socketio's auto-reconnect to come
261270
# back, then `_on_connect` will re-emit `resume`.
262271
logger.debug("sio disconnect; will resume after reconnect")
263272

264-
sio.on("connect", _on_connect)
265-
sio.on("disconnect", _on_disconnect)
273+
sio.on("connect", _on_connect, namespace=RPC_NAMESPACE)
274+
sio.on("disconnect", _on_disconnect, namespace=RPC_NAMESPACE)
266275

267-
namespaces = ["/"]
276+
namespaces = [RPC_NAMESPACE]
268277
for ns in self._namespaces:
269278
sio.register_namespace(ns)
270279
if ns.namespace not in namespaces:
@@ -297,7 +306,7 @@ async def _ack(self, call_id: str) -> None:
297306
if sio is None or not sio.connected:
298307
return
299308
with contextlib.suppress(BaseException):
300-
await sio.emit("ack", pack({"call_id": call_id}))
309+
await sio.emit("ack", pack({"call_id": call_id}), namespace=RPC_NAMESPACE)
301310

302311

303312
__all__ = ["RemoteCallError", "RuntimeClient"]

agentix/runtime/server/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
msgpack request/response. Returns the result inline if it lands
1010
within the caller's `prefer_sync_ms` budget; otherwise returns
1111
`accepted` and the result follows on Socket.IO.
12-
- Socket.IO at `/socket.io/` — unary RPC on `/` (`call` / `call:result` /
12+
- Socket.IO at `/socket.io/` — unary RPC on `/rpc` (`call` / `call:result` /
1313
`call:error`, `cancel`, plus `resume`/`ack` for reconnect-safe
1414
delivery), and side-channel namespaces (`/trace`, `/log`, and
1515
plugin paths registered via `agentix.sio`).

agentix/runtime/server/sio.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
Two responsibilities:
44
5-
1. The RPC protocol on the default `/` namespace — `call` / `cancel`
5+
1. The RPC protocol on the `/rpc` namespace — `call` / `cancel`
66
/ `call:result` / `call:error`.
77
88
2. Dynamic namespace forwarding. When a worker-side `agentix.Namespace`
@@ -11,7 +11,7 @@
1111
the worker. Outbound `sio_emit` frames become real SIO emits on the
1212
corresponding namespace.
1313
14-
Reserved namespace paths (claimed by agentix-core): `/`, `/trace`,
14+
Reserved namespace paths (claimed by agentix-core): `/rpc`, `/trace`,
1515
`/log`. Plugins use their own paths (typically `/<package-name>`).
1616
"""
1717

@@ -33,6 +33,7 @@
3333
from agentix.runtime.shared.models import RemoteError, RemoteRequest
3434

3535
logger = logging.getLogger("agentix.runtime.sio")
36+
RPC_NAMESPACE = "/rpc"
3637

3738

3839
def _u(data: Any) -> dict:
@@ -149,7 +150,7 @@ async def _emit_task_result(task: asyncio.Task, call_id: str) -> None:
149150
# disconnected the emit is a no-op and the cached entry
150151
# carries the result through to the next `resume`.
151152
pending_results[call_id] = (event, frame)
152-
await sio.emit(event, pack(frame))
153+
await sio.emit(event, pack(frame), namespace=RPC_NAMESPACE)
153154

154155
def _track_call(call_id: str, task: asyncio.Task) -> None:
155156
calls[call_id] = task
@@ -189,18 +190,16 @@ async def submit_http_call(payload: dict[str, Any], *, prefer_sync_ms: int = 100
189190
# Runtime internal hook used by the HTTP fast-path endpoint.
190191
setattr(sio, "submit_http_call", submit_http_call)
191192

192-
@sio.event
193-
async def connect(sid: str, environ: dict, auth: Any = None) -> None:
193+
async def on_connect(sid: str, environ: dict, auth: Any = None) -> None:
194194
logger.debug("sio connect %s", sid)
195195

196-
@sio.event
197-
async def disconnect(sid: str) -> None:
196+
async def on_disconnect(sid: str) -> None:
198197
# Tasks intentionally outlive the connection. Their results
199198
# land in `pending_results` and will be replayed on the next
200199
# `resume`. The host may also cancel explicitly via `cancel`.
201200
logger.debug("sio disconnect %s", sid)
202201

203-
# ── RPC on `/` ───────────────────────────────────────────────
202+
# ── RPC on `/rpc` ────────────────────────────────────────────
204203

205204
async def on_call(sid: str, data: Any) -> None:
206205
payload = _u(data)
@@ -211,6 +210,7 @@ async def on_call(sid: str, data: Any) -> None:
211210
event,
212211
pack(frame),
213212
to=sid,
213+
namespace=RPC_NAMESPACE,
214214
)
215215
return
216216

@@ -240,6 +240,7 @@ async def on_cancel(sid: str, data: Any) -> None:
240240
"call:error",
241241
pack(_cancelled_error(call_id)),
242242
to=sid,
243+
namespace=RPC_NAMESPACE,
243244
)
244245

245246
async def on_resume(sid: str, data: Any) -> None:
@@ -256,7 +257,7 @@ async def on_resume(sid: str, data: Any) -> None:
256257
if cached is None:
257258
continue
258259
event, frame = cached
259-
await sio.emit(event, pack(frame), to=sid)
260+
await sio.emit(event, pack(frame), to=sid, namespace=RPC_NAMESPACE)
260261

261262
async def on_ack(sid: str, data: Any) -> None:
262263
"""Host confirms it has consumed the result. Free the slot."""
@@ -283,7 +284,7 @@ async def _on_worker_sio_frame(frame: dict[str, Any]) -> None:
283284
return
284285
await sio.emit(event, pack(frame.get("data")), namespace=namespace)
285286
elif kind == "sio_open":
286-
if namespace in opened_namespaces or namespace == "/":
287+
if namespace in opened_namespaces or namespace in {"/", RPC_NAMESPACE}:
287288
return
288289
opened_namespaces.add(namespace)
289290
_register_namespace(namespace)
@@ -306,13 +307,15 @@ async def trigger_event(self, event: str, *args: Any) -> Any:
306307

307308
worker.set_sio_handler(_on_worker_sio_frame)
308309

309-
# Register RPC handlers on `/` non-decorator-style — `@sio.on(name)`
310+
# Register RPC handlers on `/rpc` non-decorator-style — `@sio.on(name)`
310311
# decorates by side effect and pyright can't tell that the wrapped
311312
# function is still usable.
312-
sio.on("call", on_call)
313-
sio.on("cancel", on_cancel)
314-
sio.on("resume", on_resume)
315-
sio.on("ack", on_ack)
313+
sio.on("connect", on_connect, namespace=RPC_NAMESPACE)
314+
sio.on("disconnect", on_disconnect, namespace=RPC_NAMESPACE)
315+
sio.on("call", on_call, namespace=RPC_NAMESPACE)
316+
sio.on("cancel", on_cancel, namespace=RPC_NAMESPACE)
317+
sio.on("resume", on_resume, namespace=RPC_NAMESPACE)
318+
sio.on("ack", on_ack, namespace=RPC_NAMESPACE)
316319

317320
# Pre-register core namespaces so the host can connect to them
318321
# immediately — the worker subscribes lazily, but the SIO server

agentix/sio.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
88
Three reserved namespace paths are owned by agentix-core:
99
10-
- `/` — RPC (call / cancel / call:result / call:error)
10+
- `/rpc` — RPC (call / cancel / call:result / call:error)
1111
- `/trace` — Trace/Span lifecycle
1212
- `/log` — stdlib `logging` records
1313
@@ -48,7 +48,7 @@ async def fetch_remote(self, payload):
4848
Handler = Callable[[Any], Any]
4949

5050

51-
RESERVED_NAMESPACES = frozenset({"/", "/trace", "/log"})
51+
RESERVED_NAMESPACES = frozenset({"/rpc", "/trace", "/log"})
5252

5353

5454
class RemoteSioError(RuntimeError):
@@ -125,7 +125,7 @@ class Namespace:
125125
be registered explicitly via `self.on("fetch:result", handler)`.
126126
"""
127127

128-
namespace: str = "/" # subclass MUST override
128+
namespace: str = "" # subclass MUST override
129129

130130
def __init__(self, namespace: str | None = None) -> None:
131131
if namespace is not None:

docs/concepts/remote-calls.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ becomes this wire payload:
5757
runtime uses it to correlate `call:result` / `call:error` responses and
5858
to support cancellation.
5959

60-
Remote calls use Socket.IO events on the `/` namespace. HTTP is only
61-
used for `/health`.
60+
Remote calls use Socket.IO events on the `/rpc` namespace. `RuntimeClient`
61+
may use the internal HTTP `/call` fast path for short calls; accepted
62+
long-running calls still complete over `/rpc`.
6263

6364
## Example
6465

@@ -83,7 +84,7 @@ ride their own namespaces, separate from `c.remote()`:
8384

8485
| Namespace | Direction | Purpose |
8586
| --- | --- | --- |
86-
| `/` | host ↔ sandbox | `c.remote()` |
87+
| `/rpc` | host ↔ sandbox | `c.remote()` |
8788
| `/trace` | sandbox → host | span lifecycle (auto-registered) |
8889
| `/log` | sandbox → host | stdlib logging records (auto-registered) |
8990
| `/<plugin>` | both | plugin-defined events via `agentix.sio` |

docs/reference/architecture.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ blobs inside `call:result`.
6262
1. Host imports `app.run`.
6363
2. Host calls `client.remote(run, input="hello")`.
6464
3. Client builds `RemoteCallable("app::run")` and pickles `((), {"input": "hello"})`.
65-
4. Client emits `call` on Socket.IO `/`.
65+
4. Client emits `call` on Socket.IO `/rpc`.
6666
5. Runtime server forwards the request to the worker subprocess.
6767
6. Worker imports `app`, resolves `run`, unpickles args, and calls it.
6868
7. Worker pickles the return value.
@@ -74,7 +74,8 @@ blobs inside `call:result`.
7474
| Path | Carries | Wire |
7575
| --- | --- | --- |
7676
| `GET /health` | health probe | HTTP JSON |
77-
| Socket.IO `/` | `c.remote()` RPC | `call` / `call:result` / `call:error` / `cancel` |
77+
| `POST /call` | internal short-call fast path | HTTP msgpack |
78+
| Socket.IO `/rpc` | `c.remote()` RPC | `call` / `call:result` / `call:error` / `cancel` |
7879
| Socket.IO `/trace`, `/log`, `/<plugin>` | side channels | plugin-defined events (msgpack payloads) |
7980
| worker private pipe | runtime ↔ worker | length-prefixed msgpack frames |
8081

0 commit comments

Comments
 (0)