agentixx 0.2.2__tar.gz → 0.2.4__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.
- {agentixx-0.2.2 → agentixx-0.2.4}/PKG-INFO +1 -1
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/__init__.py +1 -1
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/cli/build.py +38 -19
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/nix/wrapper.nix.tmpl +7 -1
- agentixx-0.2.4/agentix/runtime/client/_sio_facade.py +87 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/server/worker/client.py +16 -1
- {agentixx-0.2.2 → agentixx-0.2.4}/pyproject.toml +1 -1
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/_namespace_target.py +9 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_namespace_roundtrip.py +47 -0
- agentixx-0.2.2/agentix/runtime/client/_sio_facade.py +0 -47
- {agentixx-0.2.2 → agentixx-0.2.4}/.claude/scheduled_tasks.lock +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/.claude/settings.local.json +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/.claude/skills/agent-integration/SKILL.md +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/.github/workflows/docs.yml +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/.github/workflows/test.yml +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/.gitignore +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/ARCHITECTURE.md +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/CLAUDE.md +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/LICENSE +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/README.md +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/ROADMAP.md +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/cli/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/cli/__main__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/cli/_resolve.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/deployment/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/deployment/_plugin.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/deployment/base.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/log/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/log/_bridge.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/nix/builder.nix +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/nix/flake.lock +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/nix/flake.nix +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/PROTOCOL.md +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/client/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/client/client.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/server/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/server/app.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/server/sio.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/server/worker/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/server/worker/__main__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/server/worker/invoker.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/server/worker/process.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/shared/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/shared/callables.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/shared/codec.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/shared/framing.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/shared/idents.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/runtime/shared/models.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/sio.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/trace/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/trace/_bridge.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/agentix/trace/processors.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/DEPLOY.md +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/concepts/bundles.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/concepts/remote-calls.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/deployment.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/development.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/docs.json +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/index.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/integrate-agent.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/integrate-dataset.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/quickstart.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/reference/architecture.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/docs/reference/cli.mdx +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/__init__.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/_concurrent_target.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/_rpc_helpers.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/_trace_target.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/_user_app_target.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/_worker_target.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/conftest.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_concurrent_remote.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_cross_process_trace.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_models.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_plugin_axes.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_plugin_registry.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_remote_importable_module.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_rpc_protocol.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/tests/test_worker_subprocess.py +0 -0
- {agentixx-0.2.2 → agentixx-0.2.4}/uv.lock +0 -0
|
@@ -24,7 +24,7 @@ from agentix.runtime.client import RemoteCallError, RuntimeClient
|
|
|
24
24
|
from agentix.runtime.client._sio_facade import AsyncClientNamespace
|
|
25
25
|
from agentix.sio import Namespace, RemoteSioError, register_namespace
|
|
26
26
|
|
|
27
|
-
__version__ = "0.2.
|
|
27
|
+
__version__ = "0.2.4"
|
|
28
28
|
|
|
29
29
|
__all__ = [
|
|
30
30
|
"AsyncClientNamespace",
|
|
@@ -87,13 +87,23 @@ def _stage_builder(dest: Path) -> None:
|
|
|
87
87
|
(dest / fname).write_bytes(src.read_bytes())
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def _discover_plugin_nix(stage_plugin_dir: Path) -> list[str]:
|
|
91
|
-
"""Find every `
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
def _discover_plugin_nix(stage_plugin_dir: Path, project_src: Path) -> list[str]:
|
|
91
|
+
"""Find every `default.nix` that contributes system binaries to the bundle.
|
|
92
|
+
|
|
93
|
+
Two sources, in order:
|
|
94
|
+
|
|
95
|
+
1. Plugin wheels installed under the `agentix` namespace
|
|
96
|
+
(`agentix.<short>/default.nix`). These ship via `pip install
|
|
97
|
+
agentix-<plugin>` and are how runtime extensions add their CLI
|
|
98
|
+
deps (e.g. `agentix-runtime-basic` adds `bash`).
|
|
99
|
+
2. The **user project root**'s own `default.nix`, if present —
|
|
100
|
+
lets a project pull in extra binaries (`claude`, `ffmpeg`, ...)
|
|
101
|
+
without packaging a fake plugin.
|
|
102
|
+
|
|
103
|
+
Each discovered file is copied into `stage_plugin_dir/<name>.nix`
|
|
104
|
+
so the flake context is self-contained; Nix won't follow absolute
|
|
105
|
+
paths outside the flake root. Returns the list of nix-relative
|
|
106
|
+
paths ready to drop into the generated wrapper flake.
|
|
97
107
|
"""
|
|
98
108
|
stage_plugin_dir.mkdir(parents=True)
|
|
99
109
|
nix_paths: list[str] = []
|
|
@@ -101,18 +111,27 @@ def _discover_plugin_nix(stage_plugin_dir: Path) -> list[str]:
|
|
|
101
111
|
try:
|
|
102
112
|
agentix_root = resources.files("agentix")
|
|
103
113
|
except (ModuleNotFoundError, FileNotFoundError):
|
|
104
|
-
|
|
114
|
+
agentix_root = None
|
|
115
|
+
|
|
116
|
+
if agentix_root is not None:
|
|
117
|
+
for entry in agentix_root.iterdir():
|
|
118
|
+
if not entry.is_dir():
|
|
119
|
+
continue
|
|
120
|
+
nix_file = entry / "default.nix"
|
|
121
|
+
if not nix_file.is_file():
|
|
122
|
+
continue
|
|
123
|
+
short = entry.name
|
|
124
|
+
target = stage_plugin_dir / f"{short}.nix"
|
|
125
|
+
target.write_bytes(nix_file.read_bytes())
|
|
126
|
+
nix_paths.append(f"./plugins/{short}.nix")
|
|
127
|
+
|
|
128
|
+
# Project root default.nix, when present.
|
|
129
|
+
project_nix = project_src / "default.nix"
|
|
130
|
+
if project_nix.is_file():
|
|
131
|
+
target = stage_plugin_dir / "project.nix"
|
|
132
|
+
target.write_bytes(project_nix.read_bytes())
|
|
133
|
+
nix_paths.append("./plugins/project.nix")
|
|
105
134
|
|
|
106
|
-
for entry in agentix_root.iterdir():
|
|
107
|
-
if not entry.is_dir():
|
|
108
|
-
continue
|
|
109
|
-
nix_file = entry / "default.nix"
|
|
110
|
-
if not nix_file.is_file():
|
|
111
|
-
continue
|
|
112
|
-
short = entry.name
|
|
113
|
-
target = stage_plugin_dir / f"{short}.nix"
|
|
114
|
-
target.write_bytes(nix_file.read_bytes())
|
|
115
|
-
nix_paths.append(f"./plugins/{short}.nix")
|
|
116
135
|
return nix_paths
|
|
117
136
|
|
|
118
137
|
|
|
@@ -274,7 +293,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
274
293
|
def _stage(stage: Path) -> None:
|
|
275
294
|
_stage_builder(stage / "_builder")
|
|
276
295
|
_stage_project(src, stage / "project")
|
|
277
|
-
plugin_paths = _discover_plugin_nix(stage / "plugins")
|
|
296
|
+
plugin_paths = _discover_plugin_nix(stage / "plugins", src)
|
|
278
297
|
wrapper = _render_wrapper(
|
|
279
298
|
name=name,
|
|
280
299
|
tag=tag,
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
outputs = { self, agentix }:
|
|
7
7
|
let
|
|
8
8
|
system = "@SYSTEM@";
|
|
9
|
-
|
|
9
|
+
# Re-import nixpkgs with `config.allowUnfree = true` so plugin
|
|
10
|
+
# `default.nix` files can pull unfree binaries (e.g. claude CLI).
|
|
11
|
+
# Plugins that don't need unfree packages are unaffected.
|
|
12
|
+
pkgs = import agentix.inputs.nixpkgs {
|
|
13
|
+
inherit system;
|
|
14
|
+
config.allowUnfree = true;
|
|
15
|
+
};
|
|
10
16
|
in {
|
|
11
17
|
packages.${system}.bundle = agentix.lib.mkBundle {
|
|
12
18
|
inherit pkgs;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Host-side namespace helpers.
|
|
2
|
+
|
|
3
|
+
`AsyncClientNamespace` is a thin subclass of `socketio.AsyncClientNamespace`
|
|
4
|
+
that msgpack-wraps event payloads — so plugin authors write
|
|
5
|
+
`await self.emit("x", {"a": 1})` and `async def on_x(self, data)`, and
|
|
6
|
+
the bytes/msgpack wire format stays internal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import socketio
|
|
16
|
+
|
|
17
|
+
from agentix.runtime.shared.codec import pack, unpack
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("agentix.runtime.client.sio")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _decode(raw: Any) -> Any:
|
|
23
|
+
if isinstance(raw, memoryview):
|
|
24
|
+
raw = raw.tobytes()
|
|
25
|
+
elif isinstance(raw, bytearray):
|
|
26
|
+
raw = bytes(raw)
|
|
27
|
+
if isinstance(raw, bytes):
|
|
28
|
+
return unpack(raw)
|
|
29
|
+
return raw
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Socket.IO lifecycle events run inline (they're cheap and ordering
|
|
33
|
+
# matters); everything else is a user data event and is detached.
|
|
34
|
+
_LIFECYCLE_EVENTS = frozenset({"connect", "disconnect", "connect_error"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncClientNamespace(socketio.AsyncClientNamespace):
|
|
38
|
+
"""`socketio.AsyncClientNamespace` with msgpack at the boundary.
|
|
39
|
+
|
|
40
|
+
Override `on_<event>` for inbound; call `await self.emit(...)` for
|
|
41
|
+
outbound. Data is plain Python — packing happens automatically.
|
|
42
|
+
|
|
43
|
+
Data-event handlers are dispatched as **detached tasks**, never
|
|
44
|
+
awaited inline. `socketio.AsyncClient` awaits `trigger_event` inside
|
|
45
|
+
its single websocket receive loop — so a slow handler (e.g. one
|
|
46
|
+
that calls a slow LLM) would stall *every* inbound event on the
|
|
47
|
+
connection, including unrelated `c.remote` results. Detaching keeps
|
|
48
|
+
the receive loop free; handler ordering per event is still
|
|
49
|
+
preserved by the order tasks are created.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
_detached_tasks: set[asyncio.Task]
|
|
53
|
+
|
|
54
|
+
async def emit(self, event: str, data: Any = None, **kwargs: Any) -> Any:
|
|
55
|
+
return await super().emit(event, pack(data), **kwargs)
|
|
56
|
+
|
|
57
|
+
async def trigger_event(self, event: str, *args: Any) -> Any:
|
|
58
|
+
if event in _LIFECYCLE_EVENTS:
|
|
59
|
+
# Lifecycle: run inline. No msgpack payload to unwrap.
|
|
60
|
+
return await super().trigger_event(event, *args)
|
|
61
|
+
|
|
62
|
+
# Data event: unwrap the msgpack payload, then dispatch detached.
|
|
63
|
+
if args and isinstance(args[0], (bytes, bytearray, memoryview)):
|
|
64
|
+
args = (_decode(args[0]),) + args[1:]
|
|
65
|
+
|
|
66
|
+
handler = getattr(self, "on_" + event, None)
|
|
67
|
+
if handler is None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
result = handler(*args)
|
|
71
|
+
if asyncio.iscoroutine(result):
|
|
72
|
+
if not hasattr(self, "_detached_tasks"):
|
|
73
|
+
self._detached_tasks = set()
|
|
74
|
+
task = asyncio.create_task(self._guard(result, event))
|
|
75
|
+
self._detached_tasks.add(task)
|
|
76
|
+
task.add_done_callback(self._detached_tasks.discard)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
async def _guard(coro: Any, event: str) -> None:
|
|
81
|
+
try:
|
|
82
|
+
await coro
|
|
83
|
+
except Exception:
|
|
84
|
+
logger.exception("namespace handler for %r raised", event)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = ["AsyncClientNamespace"]
|
|
@@ -26,6 +26,11 @@ logger = logging.getLogger("agentix.runtime.server.worker.client")
|
|
|
26
26
|
|
|
27
27
|
_WORKER_START_TIMEOUT = 15.0
|
|
28
28
|
_DEFAULT_WORKER_PATH = "/usr/local/bin:/usr/bin:/bin"
|
|
29
|
+
# Plugin `default.nix` derivations are symlink-joined into this path
|
|
30
|
+
# inside the bundle image (see `agentix/nix/builder.nix`). Worker code
|
|
31
|
+
# (`subprocess.run("claude", ...)`, `c.remote(cc.run, ...)`, ...) must
|
|
32
|
+
# be able to find those binaries by bare name.
|
|
33
|
+
_RUNTIME_BIN_PATH = "/nix/runtime/bin"
|
|
29
34
|
_STRIPPED_ENV = {
|
|
30
35
|
"LD_LIBRARY_PATH",
|
|
31
36
|
"LD_PRELOAD",
|
|
@@ -43,7 +48,17 @@ def _clean_worker_env(runtime_bin_dir: Path | None) -> dict[str, str]:
|
|
|
43
48
|
for key, value in os.environ.items()
|
|
44
49
|
if key not in _STRIPPED_ENV and not any(key.startswith(prefix) for prefix in _STRIPPED_ENV_PREFIXES)
|
|
45
50
|
}
|
|
46
|
-
|
|
51
|
+
# Build PATH from: the venv's bin (`runtime_bin_dir`), the bundle's
|
|
52
|
+
# symlink-join (`/nix/runtime/bin`), then a minimal system fallback.
|
|
53
|
+
# Inside the bundle image the first two are siblings and both must
|
|
54
|
+
# be searchable; outside the bundle, only the first one exists.
|
|
55
|
+
parts: list[str] = []
|
|
56
|
+
if runtime_bin_dir is not None:
|
|
57
|
+
parts.append(str(runtime_bin_dir))
|
|
58
|
+
if _RUNTIME_BIN_PATH not in parts:
|
|
59
|
+
parts.append(_RUNTIME_BIN_PATH)
|
|
60
|
+
parts.append(_DEFAULT_WORKER_PATH)
|
|
61
|
+
env["PATH"] = ":".join(parts)
|
|
47
62
|
return env
|
|
48
63
|
|
|
49
64
|
|
|
@@ -27,6 +27,15 @@ async def echo_via_namespace(payload: dict) -> dict:
|
|
|
27
27
|
return await svc.request("echo", payload, timeout=10.0)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
async def fire_namespace_event(payload: dict) -> str:
|
|
31
|
+
"""Emit `slow` on `/plugin-test` and return immediately — does NOT
|
|
32
|
+
wait for the host handler. Used to prove a slow host handler does
|
|
33
|
+
not stall the runtime's other traffic."""
|
|
34
|
+
svc = _get()
|
|
35
|
+
await svc.emit("slow", payload)
|
|
36
|
+
return "fired"
|
|
37
|
+
|
|
38
|
+
|
|
30
39
|
async def emit_log_line(message: str, level: str = "INFO") -> None:
|
|
31
40
|
"""Log via stdlib logging; the worker's log bridge ships it to host."""
|
|
32
41
|
import logging
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
7
|
import logging
|
|
8
|
+
import time
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
10
11
|
|
|
@@ -15,6 +16,7 @@ from tests._namespace_target import (
|
|
|
15
16
|
emit_log_line,
|
|
16
17
|
emit_log_with_exception,
|
|
17
18
|
emit_log_with_extra,
|
|
19
|
+
fire_namespace_event,
|
|
18
20
|
)
|
|
19
21
|
|
|
20
22
|
|
|
@@ -49,6 +51,51 @@ async def test_plugin_namespace_round_trip(live_server):
|
|
|
49
51
|
assert host_ns.seen[0]["data"] == {"hello": 1}
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
class _SlowHost(AsyncClientNamespace):
|
|
55
|
+
"""Host namespace whose `slow` handler blocks for a long time."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, hold: float) -> None:
|
|
58
|
+
super().__init__("/plugin-test")
|
|
59
|
+
self._hold = hold
|
|
60
|
+
self.started = False
|
|
61
|
+
self.finished = False
|
|
62
|
+
|
|
63
|
+
async def on_slow(self, data):
|
|
64
|
+
self.started = True
|
|
65
|
+
await asyncio.sleep(self._hold)
|
|
66
|
+
self.finished = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.asyncio
|
|
70
|
+
async def test_slow_namespace_handler_does_not_block_runtime(live_server):
|
|
71
|
+
"""A slow plugin handler must not stall the SIO receive loop —
|
|
72
|
+
otherwise unrelated `c.remote` results queue up behind it.
|
|
73
|
+
|
|
74
|
+
Regression: `socketio.AsyncClient` awaits `trigger_event` inline in
|
|
75
|
+
its single websocket receive loop. `AsyncClientNamespace` detaches
|
|
76
|
+
data-event handlers so a slow one can't freeze the connection.
|
|
77
|
+
"""
|
|
78
|
+
base_url = await live_server()
|
|
79
|
+
slow_host = _SlowHost(hold=30.0)
|
|
80
|
+
|
|
81
|
+
client = RuntimeClient(base_url)
|
|
82
|
+
client.register_namespace(slow_host)
|
|
83
|
+
async with client as c:
|
|
84
|
+
# Fire the event whose host handler sleeps 30s.
|
|
85
|
+
await c.remote(fire_namespace_event, {"k": "v"})
|
|
86
|
+
|
|
87
|
+
# Immediately do a normal RPC. If the slow handler blocked the
|
|
88
|
+
# receive loop, this `call:result` would be stuck behind it for
|
|
89
|
+
# ~30s. With the fix it returns near-instantly.
|
|
90
|
+
t0 = time.perf_counter()
|
|
91
|
+
result = await asyncio.wait_for(c.remote(abs, -5), timeout=10)
|
|
92
|
+
elapsed = time.perf_counter() - t0
|
|
93
|
+
|
|
94
|
+
assert result == 5
|
|
95
|
+
assert elapsed < 8.0, f"runtime stalled behind slow handler: {elapsed:.1f}s"
|
|
96
|
+
assert slow_host.started, "slow handler never ran"
|
|
97
|
+
|
|
98
|
+
|
|
52
99
|
@pytest.mark.asyncio
|
|
53
100
|
async def test_log_records_arrive_on_host(live_server):
|
|
54
101
|
"""Verify the full /log experience: plain messages, %-format args,
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""Host-side namespace helpers.
|
|
2
|
-
|
|
3
|
-
`AsyncClientNamespace` is a thin subclass of `socketio.AsyncClientNamespace`
|
|
4
|
-
that msgpack-wraps event payloads — so plugin authors write
|
|
5
|
-
`await self.emit("x", {"a": 1})` and `async def on_x(self, data)`, and
|
|
6
|
-
the bytes/msgpack wire format stays internal.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from typing import Any
|
|
12
|
-
|
|
13
|
-
import socketio
|
|
14
|
-
|
|
15
|
-
from agentix.runtime.shared.codec import pack, unpack
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _decode(raw: Any) -> Any:
|
|
19
|
-
if isinstance(raw, memoryview):
|
|
20
|
-
raw = raw.tobytes()
|
|
21
|
-
elif isinstance(raw, bytearray):
|
|
22
|
-
raw = bytes(raw)
|
|
23
|
-
if isinstance(raw, bytes):
|
|
24
|
-
return unpack(raw)
|
|
25
|
-
return raw
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class AsyncClientNamespace(socketio.AsyncClientNamespace):
|
|
29
|
-
"""`socketio.AsyncClientNamespace` with msgpack at the boundary.
|
|
30
|
-
|
|
31
|
-
Override `on_<event>` for inbound; call `await self.emit(...)` for
|
|
32
|
-
outbound. Data is plain Python — packing happens automatically.
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
async def emit(self, event: str, data: Any = None, **kwargs: Any) -> Any:
|
|
36
|
-
return await super().emit(event, pack(data), **kwargs)
|
|
37
|
-
|
|
38
|
-
async def trigger_event(self, event: str, *args: Any) -> Any:
|
|
39
|
-
# Unpack the single data payload (socketio always emits one arg
|
|
40
|
-
# for a bytes event). Lifecycle events (`connect`, `disconnect`)
|
|
41
|
-
# come with no data and we let them pass through untouched.
|
|
42
|
-
if args and isinstance(args[0], (bytes, bytearray, memoryview)):
|
|
43
|
-
args = (_decode(args[0]),) + args[1:]
|
|
44
|
-
return await super().trigger_event(event, *args)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
__all__ = ["AsyncClientNamespace"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|