agentixx 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.
- agentix/__init__.py +26 -0
- agentix/cli/__init__.py +70 -0
- agentix/cli/__main__.py +10 -0
- agentix/cli/_resolve.py +57 -0
- agentix/cli/build.py +247 -0
- agentix/deployment/__init__.py +16 -0
- agentix/deployment/_plugin.py +202 -0
- agentix/deployment/base.py +136 -0
- agentix/runtime/PROTOCOL.md +173 -0
- agentix/runtime/__init__.py +19 -0
- agentix/runtime/client/__init__.py +15 -0
- agentix/runtime/client/client.py +471 -0
- agentix/runtime/server/__init__.py +18 -0
- agentix/runtime/server/app.py +110 -0
- agentix/runtime/server/sio.py +373 -0
- agentix/runtime/server/worker/__init__.py +5 -0
- agentix/runtime/server/worker/__main__.py +6 -0
- agentix/runtime/server/worker/client.py +403 -0
- agentix/runtime/server/worker/invoker.py +328 -0
- agentix/runtime/server/worker/process.py +296 -0
- agentix/runtime/shared/__init__.py +22 -0
- agentix/runtime/shared/callables.py +46 -0
- agentix/runtime/shared/codec.py +102 -0
- agentix/runtime/shared/events.py +35 -0
- agentix/runtime/shared/frames.py +29 -0
- agentix/runtime/shared/framing.py +72 -0
- agentix/runtime/shared/idents.py +11 -0
- agentix/runtime/shared/models.py +59 -0
- agentix/runtime/shared/pump.py +47 -0
- agentix/runtime/shared/rpc.py +164 -0
- agentixx-0.1.0.dist-info/METADATA +200 -0
- agentixx-0.1.0.dist-info/RECORD +35 -0
- agentixx-0.1.0.dist-info/WHEEL +4 -0
- agentixx-0.1.0.dist-info/entry_points.txt +3 -0
- agentixx-0.1.0.dist-info/licenses/LICENSE +21 -0
agentix/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""agentix — typed remote calls for sandboxed Python modules.
|
|
2
|
+
|
|
3
|
+
Integration wheels may contribute modules under `agentix.<short>`
|
|
4
|
+
(e.g. `agentix.bash`). Extending `agentix.__path__` lets those modules
|
|
5
|
+
co-exist with the framework modules in this package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pkgutil
|
|
9
|
+
|
|
10
|
+
__path__ = pkgutil.extend_path(__path__, __name__)
|
|
11
|
+
|
|
12
|
+
from agentix.runtime.client import RemoteCallError, RuntimeClient
|
|
13
|
+
from agentix.runtime.shared.rpc import Bidi, Channel, RemoteCall, Stream, Unary
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Bidi",
|
|
19
|
+
"Channel",
|
|
20
|
+
"RemoteCall",
|
|
21
|
+
"RemoteCallError",
|
|
22
|
+
"RuntimeClient",
|
|
23
|
+
"Stream",
|
|
24
|
+
"Unary",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
agentix/cli/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""`agentix` command-line interface.
|
|
2
|
+
|
|
3
|
+
The core CLI intentionally stays narrow: `agentix build` packages a
|
|
4
|
+
project into a bundle image. Other workflows should expose their own
|
|
5
|
+
`console_scripts` entry instead of expanding the central CLI.
|
|
6
|
+
|
|
7
|
+
The CLI deliberately doesn't use argparse subparsers — argparse
|
|
8
|
+
intercepts `--help` greedily at the root level, so `agentix build --help`
|
|
9
|
+
would never reach `build`'s parser. Manual routing keeps each
|
|
10
|
+
subcommand's `--help` intact.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import inspect
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
|
|
20
|
+
# Built-in subcommands. Each value names the submodule under
|
|
21
|
+
# `agentix.cli` whose `main(argv)` handles the verb.
|
|
22
|
+
_COMMANDS: tuple[tuple[str, str], ...] = (
|
|
23
|
+
("build", "agentix.cli.build"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _first_doc_line(obj: object) -> str:
|
|
28
|
+
"""First non-empty line of an object's docstring, or empty."""
|
|
29
|
+
doc = inspect.getdoc(obj) or ""
|
|
30
|
+
return next((line.strip() for line in doc.splitlines() if line.strip()), "")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load(module_name: str) -> Callable[[list[str]], int]:
|
|
34
|
+
return importlib.import_module(module_name).main # type: ignore[no-any-return]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _describe(module_name: str) -> str:
|
|
38
|
+
"""Description for help output. main() docstring → module docstring → empty."""
|
|
39
|
+
mod = importlib.import_module(module_name)
|
|
40
|
+
desc = _first_doc_line(getattr(mod, "main", None))
|
|
41
|
+
return desc or _first_doc_line(mod)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _print_root_help() -> None:
|
|
45
|
+
print("usage: agentix <command> [args...]\n")
|
|
46
|
+
print("Agentix developer CLI\n")
|
|
47
|
+
print("commands:")
|
|
48
|
+
width = max(len(name) for name, _ in _COMMANDS) + 2
|
|
49
|
+
for name, mod in _COMMANDS:
|
|
50
|
+
print(f" {name.ljust(width)}{_describe(mod)}")
|
|
51
|
+
print("\nRun `agentix <command> --help` for command-specific options.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
55
|
+
if argv is None:
|
|
56
|
+
argv = sys.argv[1:]
|
|
57
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
58
|
+
_print_root_help()
|
|
59
|
+
return 0
|
|
60
|
+
cmd, *rest = argv
|
|
61
|
+
for name, mod in _COMMANDS:
|
|
62
|
+
if name == cmd:
|
|
63
|
+
return _load(mod)(rest)
|
|
64
|
+
print(f"unknown command: {cmd!r}\n", file=sys.stderr)
|
|
65
|
+
_print_root_help()
|
|
66
|
+
return 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
sys.exit(main())
|
agentix/cli/__main__.py
ADDED
agentix/cli/_resolve.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Read a project's pyproject.toml and derive build metadata.
|
|
2
|
+
|
|
3
|
+
`agentix build` takes one project root — a directory containing
|
|
4
|
+
`pyproject.toml`. Plugins (other `agentix-*` packages) are pulled in
|
|
5
|
+
transitively via pip from the user's declared `[project].dependencies`;
|
|
6
|
+
neither the CLI nor the user enumerates them on the command line.
|
|
7
|
+
|
|
8
|
+
This module owns the small bit of metadata extraction the build needs:
|
|
9
|
+
* `read_pyproject(path)` — parse the project's pyproject.toml.
|
|
10
|
+
* `short_name(pyproject)` — display/tag short name derived from the
|
|
11
|
+
distribution name.
|
|
12
|
+
* `derive_tag(pyproject)` — `<short>:<version>` from name+version.
|
|
13
|
+
|
|
14
|
+
There's no multi-spec resolver, no PyPI fallback, no path-vs-image
|
|
15
|
+
disambiguation. The spec is always a local project root.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import tomllib
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_pyproject(project_dir: Path) -> dict:
|
|
27
|
+
pp = project_dir / "pyproject.toml"
|
|
28
|
+
if not pp.is_file():
|
|
29
|
+
raise SystemExit(f"{project_dir}: missing pyproject.toml")
|
|
30
|
+
with pp.open("rb") as f:
|
|
31
|
+
return tomllib.load(f)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def short_name(pyproject: dict) -> str:
|
|
35
|
+
"""Derive a short display/tag name for the project.
|
|
36
|
+
|
|
37
|
+
The short name only affects the image tag and a few build
|
|
38
|
+
diagnostics — wire routing is by `fn.__module__`, which is
|
|
39
|
+
determined by the user's actual Python import path.
|
|
40
|
+
"""
|
|
41
|
+
project = pyproject.get("project", {})
|
|
42
|
+
name = project.get("name", "")
|
|
43
|
+
if not isinstance(name, str) or not name:
|
|
44
|
+
raise SystemExit("pyproject.toml: [project].name is required")
|
|
45
|
+
return name.removeprefix("agentix-")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def derive_tag(pyproject: dict) -> str:
|
|
49
|
+
"""`<short>:<version>` from the pyproject."""
|
|
50
|
+
project = pyproject.get("project", {})
|
|
51
|
+
version = project.get("version")
|
|
52
|
+
if not isinstance(version, str):
|
|
53
|
+
raise SystemExit("pyproject.toml: [project].version is required")
|
|
54
|
+
return f"{short_name(pyproject)}:{version}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["REPO_ROOT", "derive_tag", "read_pyproject", "short_name"]
|
agentix/cli/build.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""`agentix build` — package a Python project into a deploy-ready sandbox image.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
|
|
5
|
+
agentix build # current directory's pyproject
|
|
6
|
+
agentix build path/to/project # explicit project root
|
|
7
|
+
agentix build . -o my:0.1.0 # explicit image tag
|
|
8
|
+
agentix build . --dry-run # stage Dockerfile to ./build/<tag>/, no docker invoke
|
|
9
|
+
|
|
10
|
+
The single argument is a path to a directory containing `pyproject.toml`.
|
|
11
|
+
That project's `[project].dependencies` are the bundle's plugin set —
|
|
12
|
+
pip resolves them transitively, and the resulting wheels install into
|
|
13
|
+
the framework's venv at `/nix/runtime/`. At runtime, calls execute the
|
|
14
|
+
pickle-serialized callable payload. **Neither the CLI nor the user
|
|
15
|
+
enumerates plugins on the command line.**
|
|
16
|
+
|
|
17
|
+
Build shape:
|
|
18
|
+
|
|
19
|
+
* **One pip install for the whole bundle.** Your project + every
|
|
20
|
+
declared `agentix-*` dep + transitive deps install into a single
|
|
21
|
+
/nix/runtime/ venv. Inline imports across plugins work as regular
|
|
22
|
+
Python (`from agentix.bash import run` inside your worker resolves
|
|
23
|
+
because bash is in the same site-packages).
|
|
24
|
+
|
|
25
|
+
* **System binaries via Nix (optional).** If the project root carries
|
|
26
|
+
a `default.nix`, a Nix builder stage runs first; the derivation's
|
|
27
|
+
`bin/*` is symlinked into `/nix/runtime/bin/` so user code can
|
|
28
|
+
`subprocess.run("git", …)` without absolute paths. Plugins that
|
|
29
|
+
need their own system binaries are expected to declare them in the
|
|
30
|
+
user's project `default.nix` for now.
|
|
31
|
+
|
|
32
|
+
The output image extends `agentix/runtime:<framework-version>` (override
|
|
33
|
+
via `--runtime-image`). That base image must already exist locally —
|
|
34
|
+
build it from `Agentix-Runtime-Basic/runtime/Dockerfile` or pull from
|
|
35
|
+
a registry.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import shutil
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
from collections.abc import Sequence
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from tempfile import TemporaryDirectory
|
|
47
|
+
|
|
48
|
+
from agentix import __version__ as FRAMEWORK_VERSION
|
|
49
|
+
from agentix.cli._resolve import REPO_ROOT, derive_tag, read_pyproject, short_name
|
|
50
|
+
|
|
51
|
+
_SOURCE_SKIP = {
|
|
52
|
+
"__pycache__", ".venv", "build", "dist", ".git",
|
|
53
|
+
".pytest_cache", ".ruff_cache", ".mypy_cache",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _has_system_deps(src: Path) -> bool:
|
|
58
|
+
"""True if the project ships a `default.nix` next to its pyproject."""
|
|
59
|
+
return (src / "default.nix").is_file()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _image_exists_locally(image: str) -> bool:
|
|
63
|
+
"""`docker image inspect` returns 0 iff the image is locally present."""
|
|
64
|
+
proc = subprocess.run(
|
|
65
|
+
["docker", "image", "inspect", image],
|
|
66
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
67
|
+
)
|
|
68
|
+
return proc.returncode == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ensure_runtime_image(runtime_image: str) -> None:
|
|
72
|
+
"""Verify the runtime base image exists locally.
|
|
73
|
+
|
|
74
|
+
The Dockerfile ships with `agentix-runtime-basic`; users build it
|
|
75
|
+
from that repo or pull it from a registry. The bundle build
|
|
76
|
+
doesn't auto-build the base — making the user own that step keeps
|
|
77
|
+
`agentix build`'s scope narrow.
|
|
78
|
+
"""
|
|
79
|
+
if _image_exists_locally(runtime_image):
|
|
80
|
+
return
|
|
81
|
+
raise SystemExit(
|
|
82
|
+
f"runtime image {runtime_image!r} not found locally. Build it from "
|
|
83
|
+
f"Agentix-Runtime-Basic (`runtime/Dockerfile`) or pull it from your "
|
|
84
|
+
f"registry, then re-run `agentix build`."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _stage(src: Path, build_dir: Path) -> None:
|
|
89
|
+
"""Copy the project tree into `build_dir/project/`, skipping caches."""
|
|
90
|
+
dest = build_dir / "project"
|
|
91
|
+
dest.mkdir()
|
|
92
|
+
for item in src.iterdir():
|
|
93
|
+
if item.name in _SOURCE_SKIP or item.name.endswith(".egg-info"):
|
|
94
|
+
continue
|
|
95
|
+
d = dest / item.name
|
|
96
|
+
if item.is_dir():
|
|
97
|
+
shutil.copytree(item, d, ignore=shutil.ignore_patterns(*_SOURCE_SKIP))
|
|
98
|
+
else:
|
|
99
|
+
shutil.copy2(item, d)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _render_dockerfile(src: Path, runtime_image: str) -> str:
|
|
103
|
+
"""Generate the bundle's Dockerfile.
|
|
104
|
+
|
|
105
|
+
Two stages, both optional in different ways:
|
|
106
|
+
|
|
107
|
+
Stage 1 (Nix builder, only if project has `default.nix`): build
|
|
108
|
+
the derivation, stash its closure + store-path so the next stage
|
|
109
|
+
can symlink binaries.
|
|
110
|
+
|
|
111
|
+
Stage 2 (final image): `FROM <runtime>`, copy the project source,
|
|
112
|
+
one `pip install /src/project` to merge everything into
|
|
113
|
+
`/nix/runtime/`. If a Nix derivation was built, symlink its
|
|
114
|
+
`bin/*` into `/nix/runtime/bin/`.
|
|
115
|
+
"""
|
|
116
|
+
has_nix = _has_system_deps(src)
|
|
117
|
+
parts: list[str] = ["# Generated by `agentix build`. Do not hand-edit."]
|
|
118
|
+
|
|
119
|
+
if has_nix:
|
|
120
|
+
parts += [
|
|
121
|
+
"ARG NIX_IMAGE=nixos/nix:latest",
|
|
122
|
+
"",
|
|
123
|
+
"FROM ${NIX_IMAGE} AS sys-builder",
|
|
124
|
+
"RUN mkdir -p ~/.config/nix && \\",
|
|
125
|
+
" echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf && \\",
|
|
126
|
+
" nix-channel --update 2>/dev/null || true",
|
|
127
|
+
"RUN mkdir -p /export/nix/store /export/nix/.sys-paths",
|
|
128
|
+
"",
|
|
129
|
+
"WORKDIR /src/project",
|
|
130
|
+
"COPY project/ ./",
|
|
131
|
+
"RUN nix-build --no-out-link default.nix -o ./result && \\",
|
|
132
|
+
" STORE_PATH=$(readlink -f ./result) && \\",
|
|
133
|
+
" for p in $(nix-store -qR \"$STORE_PATH\"); do \\",
|
|
134
|
+
" cp -a \"$p\" /export/nix/store/ || true; \\",
|
|
135
|
+
" done && \\",
|
|
136
|
+
" echo \"$STORE_PATH\" > /export/nix/.sys-paths/project",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
parts += ["", f"FROM {runtime_image}"]
|
|
140
|
+
if has_nix:
|
|
141
|
+
parts += [
|
|
142
|
+
"COPY --from=sys-builder /export/nix/store /nix/store",
|
|
143
|
+
"COPY --from=sys-builder /export/nix/.sys-paths /nix/.sys-paths",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# Stage the source + install. Pip resolves the project's declared
|
|
147
|
+
# deps (including transitive agentix-* plugins) into /nix/runtime/.
|
|
148
|
+
parts += [
|
|
149
|
+
"COPY project/ /src/project/",
|
|
150
|
+
"RUN /nix/runtime/bin/pip install --no-cache-dir /src/project",
|
|
151
|
+
]
|
|
152
|
+
if has_nix:
|
|
153
|
+
parts += [
|
|
154
|
+
"RUN SP=$(cat /nix/.sys-paths/project) && \\",
|
|
155
|
+
" for f in $SP/bin/*; do \\",
|
|
156
|
+
" ln -sf \"$f\" /nix/runtime/bin/$(basename \"$f\"); \\",
|
|
157
|
+
" done",
|
|
158
|
+
]
|
|
159
|
+
return "\n".join(parts) + "\n"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
163
|
+
parser = argparse.ArgumentParser(
|
|
164
|
+
prog="agentix build",
|
|
165
|
+
description=__doc__,
|
|
166
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"path", nargs="?", default=".",
|
|
170
|
+
help="project root with pyproject.toml (default: current directory)",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"-o", "--output", "--tag", dest="output", default=None,
|
|
174
|
+
help="output image tag. Defaults to <name>:<version> from pyproject.",
|
|
175
|
+
)
|
|
176
|
+
parser.add_argument(
|
|
177
|
+
"--runtime-image", default=f"agentix/runtime:{FRAMEWORK_VERSION}",
|
|
178
|
+
help="base runtime image. Must exist locally; build from "
|
|
179
|
+
"Agentix-Runtime-Basic/runtime/Dockerfile or pull from your "
|
|
180
|
+
f"registry. (default: agentix/runtime:{FRAMEWORK_VERSION})",
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"--dry-run", action="store_true",
|
|
184
|
+
help="stage Dockerfile to ./build/<tag>/; do not invoke docker",
|
|
185
|
+
)
|
|
186
|
+
args = parser.parse_args(argv)
|
|
187
|
+
|
|
188
|
+
src = Path(args.path).resolve()
|
|
189
|
+
if not src.is_dir():
|
|
190
|
+
raise SystemExit(f"{src}: not a directory")
|
|
191
|
+
pp = read_pyproject(src)
|
|
192
|
+
|
|
193
|
+
tag = args.output or derive_tag(pp)
|
|
194
|
+
if ":" not in tag:
|
|
195
|
+
raise SystemExit(f"image tag must include `:<version>` (got {tag!r})")
|
|
196
|
+
|
|
197
|
+
short = short_name(pp)
|
|
198
|
+
|
|
199
|
+
if args.dry_run:
|
|
200
|
+
out = REPO_ROOT / "build" / tag.rsplit("/", 1)[-1].split(":", 1)[0]
|
|
201
|
+
if out.exists():
|
|
202
|
+
shutil.rmtree(out)
|
|
203
|
+
out.mkdir(parents=True)
|
|
204
|
+
_stage(src, out)
|
|
205
|
+
(out / "Dockerfile").write_text(_render_dockerfile(src, args.runtime_image))
|
|
206
|
+
print(f"staged build context → {out}")
|
|
207
|
+
print(f"would build → {tag}")
|
|
208
|
+
print(f" extends → {args.runtime_image}")
|
|
209
|
+
print(f" project → {short}")
|
|
210
|
+
print(f" nix derivation → {'yes' if _has_system_deps(src) else 'no'}")
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
_ensure_runtime_image(args.runtime_image)
|
|
214
|
+
|
|
215
|
+
with TemporaryDirectory(prefix="agentix-build-") as tmp:
|
|
216
|
+
build_dir = Path(tmp)
|
|
217
|
+
_stage(src, build_dir)
|
|
218
|
+
(build_dir / "Dockerfile").write_text(_render_dockerfile(src, args.runtime_image))
|
|
219
|
+
print(
|
|
220
|
+
f"building {tag} (project {short!r} extending {args.runtime_image})…",
|
|
221
|
+
file=sys.stderr,
|
|
222
|
+
)
|
|
223
|
+
proc = subprocess.run(
|
|
224
|
+
["docker", "build", "-t", tag, str(build_dir)],
|
|
225
|
+
check=False,
|
|
226
|
+
stderr=subprocess.PIPE,
|
|
227
|
+
)
|
|
228
|
+
if proc.stderr:
|
|
229
|
+
sys.stderr.buffer.write(proc.stderr)
|
|
230
|
+
if proc.returncode != 0:
|
|
231
|
+
err = proc.stderr.decode(errors="replace") if proc.stderr else ""
|
|
232
|
+
if any(
|
|
233
|
+
token in err
|
|
234
|
+
for token in ("conflict", "no matching distribution", "incompatible", "ResolutionImpossible")
|
|
235
|
+
):
|
|
236
|
+
print(
|
|
237
|
+
"\nhint: pip couldn't resolve the unified dep set. Pin a "
|
|
238
|
+
"compatible version pair across your `agentix-*` deps in "
|
|
239
|
+
"pyproject.toml, or split the conflicting plugins into "
|
|
240
|
+
"two bundles.",
|
|
241
|
+
file=sys.stderr,
|
|
242
|
+
)
|
|
243
|
+
return proc.returncode
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Deployment Protocol + backend discovery.
|
|
2
|
+
|
|
3
|
+
Core ships the `Deployment` Protocol, `Sandbox` dataclass, and backend
|
|
4
|
+
registry. Backend wheels (`agentix-deployment-docker`, `-daytona`,
|
|
5
|
+
`-e2b`, third-party) each install a sibling module under
|
|
6
|
+
`agentix.deployment`; extending `__path__` lets those siblings co-exist
|
|
7
|
+
with the framework files in this directory.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pkgutil
|
|
11
|
+
|
|
12
|
+
__path__ = pkgutil.extend_path(__path__, __name__)
|
|
13
|
+
|
|
14
|
+
from agentix.deployment.base import Deployment
|
|
15
|
+
|
|
16
|
+
__all__ = ["Deployment"]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Plugin registry for the `agentix.deployment` entry-point group.
|
|
2
|
+
|
|
3
|
+
Lives under `agentix.deployment` because deployments are the only
|
|
4
|
+
framework axis discovered via entry points. Remote callables do not need
|
|
5
|
+
a framework registry; only sandbox lifecycle backends need a name-to-class
|
|
6
|
+
registry.
|
|
7
|
+
|
|
8
|
+
Two ways to find plugins:
|
|
9
|
+
|
|
10
|
+
* **Production:** `importlib.metadata.entry_points(group=...)` — what
|
|
11
|
+
`pip install some-plugin` populates. The dist's `pyproject.toml`
|
|
12
|
+
declares its entries; nothing in the framework changes when a new
|
|
13
|
+
plugin is installed.
|
|
14
|
+
* **Testing / dynamic:** the in-process `register(name, factory)`
|
|
15
|
+
method. Convenient for unit tests, fixtures, or programmatic
|
|
16
|
+
composition; not part of the documented user-facing surface.
|
|
17
|
+
|
|
18
|
+
Lookup is lazy — entry points are walked on the first `get()` /
|
|
19
|
+
`all()` call. Loaders that raise are caught and remembered per-entry
|
|
20
|
+
(`errors()`), so one broken plugin doesn't poison the rest.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib.metadata
|
|
26
|
+
import logging
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Generic, TypeVar
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("agentix.deployment.plugin")
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class PluginSource:
|
|
38
|
+
"""Where a registered plugin came from — used in conflict reports
|
|
39
|
+
and CLI listings."""
|
|
40
|
+
|
|
41
|
+
dist_name: str | None
|
|
42
|
+
dist_version: str | None
|
|
43
|
+
|
|
44
|
+
def label(self) -> str:
|
|
45
|
+
if self.dist_name:
|
|
46
|
+
return f"{self.dist_name}@{self.dist_version or '?'}"
|
|
47
|
+
return "(in-process)"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PluginConflictError(RuntimeError):
|
|
51
|
+
"""Two plugins claim the same name in the same group.
|
|
52
|
+
|
|
53
|
+
The framework refuses to silently last-wins because it would hide
|
|
54
|
+
bugs (e.g. a stale wheel from a previous install). Users see the
|
|
55
|
+
conflict on first registry access and uninstall the duplicate dist.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Registry(Generic[T]):
|
|
60
|
+
"""One `agentix.<axis>` entry-point group + in-process `register()`.
|
|
61
|
+
|
|
62
|
+
`T` is whatever the entry-point load resolves to — typically a class
|
|
63
|
+
(`type[Deployment]`) or a callable factory. The registry doesn't
|
|
64
|
+
instantiate anything; callers decide what to do with the loaded
|
|
65
|
+
value. This keeps the contract narrow: T's shape is the axis's
|
|
66
|
+
Protocol; how it gets instantiated is the axis's concern.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, group: str) -> None:
|
|
70
|
+
self._group = group
|
|
71
|
+
self._extra: dict[str, tuple[Callable[[], T], PluginSource]] = {}
|
|
72
|
+
# Lazy: populated on first _load().
|
|
73
|
+
self._cache: dict[str, T] | None = None
|
|
74
|
+
self._sources: dict[str, PluginSource] = {}
|
|
75
|
+
self._errors: dict[str, Exception] = {}
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def group(self) -> str:
|
|
79
|
+
return self._group
|
|
80
|
+
|
|
81
|
+
def register(
|
|
82
|
+
self,
|
|
83
|
+
name: str,
|
|
84
|
+
factory: Callable[[], T],
|
|
85
|
+
*,
|
|
86
|
+
dist_name: str | None = None,
|
|
87
|
+
dist_version: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Register a plugin imperatively.
|
|
90
|
+
|
|
91
|
+
Intended for tests and programmatic composition; production
|
|
92
|
+
plugins use entry points. Calling `register()` invalidates the
|
|
93
|
+
cache so the next lookup re-runs the merge.
|
|
94
|
+
"""
|
|
95
|
+
self._extra[name] = (factory, PluginSource(dist_name, dist_version))
|
|
96
|
+
self._cache = None # invalidate
|
|
97
|
+
|
|
98
|
+
def _walk_entry_points(self) -> list[tuple[str, Callable[[], T], PluginSource]]:
|
|
99
|
+
eps = importlib.metadata.entry_points()
|
|
100
|
+
# Python 3.10+: SelectableGroups; earlier: dict (we target 3.11+ but
|
|
101
|
+
# mirror the standard library's defensive branch anyway).
|
|
102
|
+
selected = (
|
|
103
|
+
list(eps.select(group=self._group))
|
|
104
|
+
if hasattr(eps, "select")
|
|
105
|
+
else list(eps.get(self._group, [])) # type: ignore[attr-defined]
|
|
106
|
+
)
|
|
107
|
+
out: list[tuple[str, Callable[[], T], PluginSource]] = []
|
|
108
|
+
for ep in selected:
|
|
109
|
+
dist = ep.dist
|
|
110
|
+
src = PluginSource(
|
|
111
|
+
dist_name=getattr(dist, "name", None) if dist else None,
|
|
112
|
+
dist_version=getattr(dist, "version", None) if dist else None,
|
|
113
|
+
)
|
|
114
|
+
out.append((ep.name, ep.load, src))
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def _load(self) -> dict[str, T]:
|
|
118
|
+
if self._cache is not None:
|
|
119
|
+
return self._cache
|
|
120
|
+
|
|
121
|
+
items: dict[str, T] = {}
|
|
122
|
+
sources: dict[str, PluginSource] = {}
|
|
123
|
+
errors: dict[str, Exception] = {}
|
|
124
|
+
|
|
125
|
+
# Entry-point pass first. Two dists declaring the same name is
|
|
126
|
+
# ambiguous and must surface — they'd silently last-wins otherwise.
|
|
127
|
+
for name, loader, src in self._walk_entry_points():
|
|
128
|
+
if name in sources:
|
|
129
|
+
raise PluginConflictError(
|
|
130
|
+
f"duplicate plugin {name!r} in group {self._group!r}: "
|
|
131
|
+
f"{sources[name].label()} vs {src.label()}"
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
items[name] = loader()
|
|
135
|
+
sources[name] = src
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
logger.warning(
|
|
138
|
+
"plugin %r in group %r failed to load: %s",
|
|
139
|
+
name, self._group, exc,
|
|
140
|
+
)
|
|
141
|
+
errors[name] = exc
|
|
142
|
+
|
|
143
|
+
# In-process extras override entry points — this is deliberate
|
|
144
|
+
# for tests (`register("local", FakeDocker)` swaps in a stub).
|
|
145
|
+
# Production code paths don't call `register()`, so this is safe.
|
|
146
|
+
for name, (factory, src) in self._extra.items():
|
|
147
|
+
try:
|
|
148
|
+
items[name] = factory()
|
|
149
|
+
sources[name] = src
|
|
150
|
+
errors.pop(name, None)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
errors[name] = exc
|
|
153
|
+
|
|
154
|
+
self._cache = items
|
|
155
|
+
self._sources = sources
|
|
156
|
+
self._errors = errors
|
|
157
|
+
return items
|
|
158
|
+
|
|
159
|
+
def get(self, name: str) -> T:
|
|
160
|
+
"""Return the plugin registered under `name`.
|
|
161
|
+
|
|
162
|
+
Raises `KeyError` if no plugin claims the name (with the list of
|
|
163
|
+
available names in the error message), or re-raises the original
|
|
164
|
+
exception if the named plugin failed to load.
|
|
165
|
+
"""
|
|
166
|
+
items = self._load()
|
|
167
|
+
if name in items:
|
|
168
|
+
return items[name]
|
|
169
|
+
if name in self._errors:
|
|
170
|
+
raise self._errors[name]
|
|
171
|
+
raise KeyError(
|
|
172
|
+
f"no plugin {name!r} in group {self._group!r}; "
|
|
173
|
+
f"available: {sorted(items)}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def all(self) -> dict[str, T]:
|
|
177
|
+
"""Snapshot of all successfully-loaded plugins, name → value."""
|
|
178
|
+
return dict(self._load())
|
|
179
|
+
|
|
180
|
+
def sources(self) -> dict[str, PluginSource]:
|
|
181
|
+
"""`name → PluginSource` for every successfully-loaded plugin."""
|
|
182
|
+
self._load()
|
|
183
|
+
return dict(self._sources)
|
|
184
|
+
|
|
185
|
+
def errors(self) -> dict[str, Exception]:
|
|
186
|
+
"""`name → Exception` for plugins whose load failed (cached)."""
|
|
187
|
+
self._load()
|
|
188
|
+
return dict(self._errors)
|
|
189
|
+
|
|
190
|
+
def reset(self) -> None:
|
|
191
|
+
"""Test-only: drop cache + in-process registrations.
|
|
192
|
+
|
|
193
|
+
Useful in pytest fixtures that need a known-empty registry
|
|
194
|
+
between tests.
|
|
195
|
+
"""
|
|
196
|
+
self._cache = None
|
|
197
|
+
self._extra.clear()
|
|
198
|
+
self._sources.clear()
|
|
199
|
+
self._errors.clear()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = ["PluginConflictError", "PluginSource", "Registry"]
|