agent-sandbox-server 0.3.1a1__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.
Files changed (63) hide show
  1. agent_sandbox/server/__init__.py +64 -0
  2. agent_sandbox/server/__main__.py +224 -0
  3. agent_sandbox/server/_coerce.py +169 -0
  4. agent_sandbox/server/_version.py +1 -0
  5. agent_sandbox/server/agents/__init__.py +66 -0
  6. agent_sandbox/server/agents/adapters/__init__.py +140 -0
  7. agent_sandbox/server/agents/adapters/base.py +224 -0
  8. agent_sandbox/server/agents/adapters/claude_code.py +132 -0
  9. agent_sandbox/server/agents/adapters/codex.py +303 -0
  10. agent_sandbox/server/agents/adapters/command.py +126 -0
  11. agent_sandbox/server/agents/adapters/cursor.py +248 -0
  12. agent_sandbox/server/agents/adapters/qwen_code.py +135 -0
  13. agent_sandbox/server/agents/adapters/stream_json.py +269 -0
  14. agent_sandbox/server/agents/api_common.py +654 -0
  15. agent_sandbox/server/agents/api_definitions.py +445 -0
  16. agent_sandbox/server/agents/api_environments.py +568 -0
  17. agent_sandbox/server/agents/api_other.py +345 -0
  18. agent_sandbox/server/agents/api_responses.py +1051 -0
  19. agent_sandbox/server/agents/api_sessions.py +732 -0
  20. agent_sandbox/server/agents/api_vaults.py +539 -0
  21. agent_sandbox/server/agents/artifacts.py +201 -0
  22. agent_sandbox/server/agents/config.py +558 -0
  23. agent_sandbox/server/agents/errors.py +100 -0
  24. agent_sandbox/server/agents/events.py +155 -0
  25. agent_sandbox/server/agents/local_state.py +173 -0
  26. agent_sandbox/server/agents/procenv.py +221 -0
  27. agent_sandbox/server/agents/redact.py +94 -0
  28. agent_sandbox/server/agents/runtime.py +1481 -0
  29. agent_sandbox/server/agents/store.py +126 -0
  30. agent_sandbox/server/agents/subagents.py +237 -0
  31. agent_sandbox/server/agents/webhooks.py +206 -0
  32. agent_sandbox/server/agents/wire_sessions.py +340 -0
  33. agent_sandbox/server/agents/workspace_files.py +244 -0
  34. agent_sandbox/server/app.py +727 -0
  35. agent_sandbox/server/contract.py +41 -0
  36. agent_sandbox/server/endpoints.py +892 -0
  37. agent_sandbox/server/guard.py +230 -0
  38. agent_sandbox/server/guards/__init__.py +56 -0
  39. agent_sandbox/server/guards/commands.py +299 -0
  40. agent_sandbox/server/guards/env.py +64 -0
  41. agent_sandbox/server/guards/paths.py +99 -0
  42. agent_sandbox/server/guards/process.py +60 -0
  43. agent_sandbox/server/guards/urls.py +57 -0
  44. agent_sandbox/server/policy.py +817 -0
  45. agent_sandbox/server/py.typed +0 -0
  46. agent_sandbox/server/registry.py +360 -0
  47. agent_sandbox/server/router.py +302 -0
  48. agent_sandbox/server/routes.py +327 -0
  49. agent_sandbox/server/routes_agents.py +627 -0
  50. agent_sandbox/server/routes_browser.py +546 -0
  51. agent_sandbox/server/routes_devtools.py +433 -0
  52. agent_sandbox/server/routes_files.py +751 -0
  53. agent_sandbox/server/routes_process.py +483 -0
  54. agent_sandbox/server/routes_pty.py +772 -0
  55. agent_sandbox/server/routes_system.py +523 -0
  56. agent_sandbox/server/runtime.py +793 -0
  57. agent_sandbox/server/types.py +107 -0
  58. agent_sandbox_server-0.3.1a1.dist-info/METADATA +86 -0
  59. agent_sandbox_server-0.3.1a1.dist-info/RECORD +63 -0
  60. agent_sandbox_server-0.3.1a1.dist-info/WHEEL +4 -0
  61. agent_sandbox_server-0.3.1a1.dist-info/entry_points.txt +2 -0
  62. agent_sandbox_server-0.3.1a1.dist-info/licenses/LICENSE +200 -0
  63. agent_sandbox_server-0.3.1a1.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,64 @@
1
+ """Container-side HTTP server — stdlib-only, zero external dependencies.
2
+
3
+ Quick start (inside a sandbox template's ``commands.py``)::
4
+
5
+ from agent_sandbox.server import start
6
+ start(9000)
7
+
8
+ Or for more control::
9
+
10
+ from agent_sandbox.server import SandboxServer, ServerPolicy, command
11
+
12
+ @command("greet")
13
+ def greet(name: str) -> str:
14
+ return f"Hello, {name}!"
15
+
16
+ server = SandboxServer(host="0.0.0.0", policy=ServerPolicy.only("transfer"))
17
+ server.serve(port=9000)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from ._version import __version__
23
+ from .app import SandboxServer, start
24
+ from .endpoints import CapabilityGroup
25
+ from .policy import PolicyDenied, ServerPolicy
26
+ from .registry import (
27
+ CommandArg,
28
+ CommandRegistry,
29
+ RegisteredCommand,
30
+ command,
31
+ default_registry,
32
+ )
33
+ from .router import RouteInfo, RouteTable, default_table
34
+ from .routes_pty import (
35
+ PtySession,
36
+ PtySessionManager,
37
+ pty_session_manager,
38
+ start_pty_server,
39
+ )
40
+ from .types import ServerRequest, ServerResponse, SSEResponse
41
+
42
+ __all__ = [
43
+ "__version__",
44
+ "SandboxServer",
45
+ "start",
46
+ "ServerPolicy",
47
+ "PolicyDenied",
48
+ "CommandArg",
49
+ "CommandRegistry",
50
+ "RegisteredCommand",
51
+ "command",
52
+ "default_registry",
53
+ "ServerRequest",
54
+ "ServerResponse",
55
+ "SSEResponse",
56
+ "RouteTable",
57
+ "RouteInfo",
58
+ "CapabilityGroup",
59
+ "default_table",
60
+ "PtySession",
61
+ "PtySessionManager",
62
+ "pty_session_manager",
63
+ "start_pty_server",
64
+ ]
@@ -0,0 +1,224 @@
1
+ """``agent-sandbox-server`` command line (also ``python -m agent_sandbox.server``).
2
+
3
+ Sub-commands:
4
+
5
+ * ``serve`` - run the HTTP (and PTY WebSocket) server.
6
+ * ``healthcheck`` - ``GET /health`` against a running server; exit code 0/1 (Docker
7
+ ``HEALTHCHECK`` / platform ``ready_cmd``).
8
+ * ``check-config`` - validate a policy, print the effective endpoint table and lint warnings.
9
+ * ``version`` - print the package version.
10
+
11
+ Stdlib only; ``healthcheck`` imports nothing heavy.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import hashlib
18
+ import importlib
19
+ import importlib.util
20
+ import json
21
+ import os
22
+ import sys
23
+ import urllib.error
24
+ import urllib.request
25
+ from typing import Any
26
+
27
+ from . import contract
28
+ from ._version import __version__
29
+
30
+ __all__ = ["main"]
31
+
32
+
33
+ def _build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="agent-sandbox-server", description=__doc__.split("\n")[0]
36
+ )
37
+ sub = parser.add_subparsers(dest="command")
38
+
39
+ serve = sub.add_parser("serve", help="run the sandbox server")
40
+ serve.add_argument("--host", default="0.0.0.0") # noqa: S104 (container-internal)
41
+ serve.add_argument("--port", type=int, default=contract.DEFAULT_HTTP_PORT)
42
+ serve.add_argument("--pty-port", type=int, default=None)
43
+ serve.add_argument(
44
+ "--commands",
45
+ action="append",
46
+ default=[],
47
+ metavar="FILE_OR_MODULE",
48
+ help="python file or module that registers commands/routes (repeatable)",
49
+ )
50
+ serve.add_argument("--policy", metavar="FILE", help="policy JSON file")
51
+ serve.add_argument("--preset", help="policy preset (default, transfer-only, ...)")
52
+ serve.add_argument("--base-dir", help="override ABX_SERVER_BASE_DIR")
53
+
54
+ health = sub.add_parser("healthcheck", help="GET /health, exit 0 when healthy")
55
+ health.add_argument("--host", default="127.0.0.1")
56
+ health.add_argument("--port", type=int, default=contract.DEFAULT_HTTP_PORT)
57
+ health.add_argument("--timeout", type=float, default=2.0)
58
+
59
+ check = sub.add_parser("check-config", help="validate and lint a policy")
60
+ check.add_argument("--policy", metavar="FILE")
61
+ check.add_argument("--preset")
62
+ check.add_argument("--json", action="store_true", help="machine-readable output")
63
+ check.add_argument("--strict", action="store_true", help="exit 2 when lint warnings exist")
64
+ check.add_argument("--explain", metavar="ENDPOINT", help="explain a decision for ENDPOINT")
65
+ check.add_argument("--path", help="path for --explain")
66
+ check.add_argument("--scopes", help="comma-separated scopes for --explain (default '*')")
67
+ check.add_argument("--op", choices=["read", "write", "delete", "exec"])
68
+
69
+ sub.add_parser("version", help="print the version")
70
+ return parser
71
+
72
+
73
+ def _load_commands(spec: str) -> None:
74
+ """Import a file or module so its registrations run."""
75
+ if spec.endswith(".py") or os.path.sep in spec or os.path.isfile(spec):
76
+ path = os.path.realpath(spec)
77
+ module_name = f"_abx_user_commands_{hashlib.sha256(os.fsencode(path)).hexdigest()}"
78
+ if module_name in sys.modules:
79
+ return
80
+ module_spec = importlib.util.spec_from_file_location(module_name, path)
81
+ if module_spec is None or module_spec.loader is None:
82
+ raise SystemExit(f"cannot load commands file {spec!r}")
83
+ module = importlib.util.module_from_spec(module_spec)
84
+ previous_path = sys.path[:]
85
+ directory = os.path.dirname(path)
86
+ if directory not in sys.path:
87
+ sys.path.insert(0, directory)
88
+ # Decorators (including dataclasses with postponed annotations) resolve
89
+ # their defining module during execution, as with a normal import.
90
+ sys.modules[module_name] = module
91
+ try:
92
+ module_spec.loader.exec_module(module)
93
+ except BaseException:
94
+ sys.modules.pop(module_name, None)
95
+ sys.path[:] = previous_path
96
+ raise
97
+ else:
98
+ importlib.import_module(spec)
99
+
100
+
101
+ def _resolve_policy(args: argparse.Namespace) -> Any:
102
+ from .policy import PolicyConfigError, ServerPolicy
103
+
104
+ try:
105
+ if args.policy:
106
+ policy = ServerPolicy.from_file(args.policy)
107
+ if args.preset:
108
+ policy = policy.with_options(preset=args.preset)
109
+ return policy
110
+ if args.preset:
111
+ return ServerPolicy.preset(args.preset)
112
+ return ServerPolicy.from_environment()
113
+ except PolicyConfigError as exc:
114
+ print(f"policy error: {exc}", file=sys.stderr)
115
+ raise SystemExit(1) from exc
116
+
117
+
118
+ def _cmd_serve(args: argparse.Namespace) -> int:
119
+ from .app import SandboxServer
120
+
121
+ if args.base_dir:
122
+ os.environ["ABX_SERVER_BASE_DIR"] = args.base_dir
123
+ for spec in args.commands:
124
+ _load_commands(spec)
125
+ policy = _resolve_policy(args)
126
+ try:
127
+ server = SandboxServer(host=args.host, pty_port=args.pty_port, policy=policy)
128
+ except ValueError as exc:
129
+ print(f"configuration error: {exc}", file=sys.stderr)
130
+ return 1
131
+ server.serve(args.port)
132
+ return 0
133
+
134
+
135
+ def _cmd_healthcheck(args: argparse.Namespace) -> int:
136
+ url = f"http://{args.host}:{args.port}/health"
137
+ try:
138
+ with urllib.request.urlopen(url, timeout=args.timeout) as resp: # noqa: S310
139
+ return 0 if resp.status == 200 else 1
140
+ except (urllib.error.URLError, OSError, ValueError):
141
+ return 1
142
+
143
+
144
+ def _cmd_check_config(args: argparse.Namespace) -> int:
145
+ from .policy import PolicyConfigError, ServerPolicy
146
+
147
+ policy = _resolve_policy(args) or ServerPolicy.from_dict({})
148
+ try:
149
+ engine = policy.bind()
150
+ except PolicyConfigError as exc:
151
+ print(f"policy error: {exc}", file=sys.stderr)
152
+ return 1
153
+ warnings = engine.lint()
154
+ if args.policy:
155
+ warnings.extend(_file_permission_warnings(args.policy))
156
+ if args.explain:
157
+ scopes = args.scopes.split(",") if args.scopes else None
158
+ decision = engine.explain(args.explain, scopes=scopes, path=args.path, op=args.op)
159
+ print(json.dumps(decision, indent=2))
160
+ return 0 if decision["allowed"] else 3
161
+ rows = []
162
+ for name in sorted(engine._rules):
163
+ rule = engine.rule_for(name)
164
+ route = engine._routes[name]
165
+ rows.append(
166
+ {
167
+ "endpoint": name,
168
+ "group": route.group.value,
169
+ "enabled": rule.enabled,
170
+ "rule": rule.disabled_by or None,
171
+ "scopes": list(rule.scopes),
172
+ "limits": dict(rule.limits),
173
+ "paths": {
174
+ "allow": list(rule.allow),
175
+ "deny": list(rule.deny),
176
+ "read_only": rule.read_only,
177
+ },
178
+ }
179
+ )
180
+ if args.json:
181
+ print(
182
+ json.dumps(
183
+ {"revision": policy.revision, "endpoints": rows, "warnings": warnings}, indent=2
184
+ )
185
+ )
186
+ else:
187
+ print(f"policy {policy!r}")
188
+ for row in rows:
189
+ flag = "on " if row["enabled"] else "off"
190
+ print(f" [{flag}] {row['endpoint']:<24} scopes={','.join(row['scopes']) or '-'}")
191
+ for warning in warnings:
192
+ print(f"warning: {warning}")
193
+ return 2 if warnings and args.strict else 0
194
+
195
+
196
+ def _file_permission_warnings(path: str) -> list[str]:
197
+ out: list[str] = []
198
+ if os.access(path, os.W_OK):
199
+ out.append(f"L5: policy file {path} is writable by the current user")
200
+ directory = os.path.dirname(os.path.abspath(path))
201
+ if os.access(directory, os.W_OK):
202
+ out.append(f"L5: policy directory {directory} is writable by the current user")
203
+ return out
204
+
205
+
206
+ def main(argv: list[str] | None = None) -> int:
207
+ """Entry point; returns the process exit code."""
208
+ parser = _build_parser()
209
+ args = parser.parse_args(argv)
210
+ command = args.command or "serve"
211
+ if command == "serve" and args.command is None:
212
+ args = parser.parse_args(["serve", *(argv or [])])
213
+ if command == "version":
214
+ print(__version__)
215
+ return 0
216
+ if command == "healthcheck":
217
+ return _cmd_healthcheck(args)
218
+ if command == "check-config":
219
+ return _cmd_check_config(args)
220
+ return _cmd_serve(args)
221
+
222
+
223
+ if __name__ == "__main__": # pragma: no cover
224
+ sys.exit(main())
@@ -0,0 +1,169 @@
1
+ """Shared scalar and string-array type coercion for command arguments.
2
+
3
+ Both the **server** layer (``server/routes.py``) and the client SDK's
4
+ **declarative** layer (``declarative/decorator.py``) need identical
5
+ coerce-and-validate logic. The server-sdk and client-sdk are separate
6
+ distributions, so each ships this module (``agent_sandbox.utils.coerce`` in the
7
+ client); the two files must stay byte-identical, which the client test-suite
8
+ enforces.
9
+
10
+ The functions accept any arg object exposing ``.name``, ``.type``,
11
+ ``.required``, and ``.default`` attributes — covering both
12
+ :class:`~agent_sandbox.server.registry.CommandArg` (stdlib dataclass) and
13
+ :class:`~agent_sandbox.models.template.CustomCommandArg` (Pydantic model).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any, Protocol, runtime_checkable
20
+
21
+ __all__ = [
22
+ "parse_scalar",
23
+ "coerce_kwargs",
24
+ ]
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Boolean whitelist
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _BOOL_TRUE: frozenset[str] = frozenset({"true", "yes", "1"})
31
+ _BOOL_FALSE: frozenset[str] = frozenset({"false", "no", "0"})
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Scalar parsing
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ def parse_scalar(value: Any, type_str: str) -> Any:
39
+ """Parse / coerce *value* according to its declared *type_str*.
40
+
41
+ Supported *type_str* values: ``"string"``, ``"integer"``, ``"float"``,
42
+ ``"boolean"``, and ``"string_array"`` (JSON arrays of strings only).
43
+
44
+ Bool parsing uses a whitelist (``true/false/yes/no/1/0``) to avoid the
45
+ ``bool("False") == True`` pitfall.
46
+
47
+ Args:
48
+ value: The raw value to coerce.
49
+ type_str: One of the supported scalar type strings.
50
+
51
+ Returns:
52
+ The coerced value.
53
+
54
+ Raises:
55
+ ValueError: If *value* cannot be converted or *type_str* is
56
+ unrecognised.
57
+ """
58
+ if type_str == "string":
59
+ return str(value)
60
+
61
+ if type_str == "integer":
62
+ if isinstance(value, bool):
63
+ raise ValueError("JSON boolean is not an integer argument")
64
+ if isinstance(value, int) and not isinstance(value, bool):
65
+ return value
66
+ return int(value)
67
+
68
+ if type_str == "float":
69
+ if isinstance(value, float):
70
+ return value
71
+ return float(value)
72
+
73
+ if type_str == "boolean":
74
+ if isinstance(value, bool):
75
+ return value
76
+ s = str(value).lower()
77
+ if s in _BOOL_TRUE:
78
+ return True
79
+ if s in _BOOL_FALSE:
80
+ return False
81
+ raise ValueError(
82
+ f"Cannot parse {value!r} as boolean; accepted values: true/false/yes/no/1/0"
83
+ )
84
+
85
+ if type_str == "string_array":
86
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
87
+ raise ValueError("Expected a JSON array containing only strings")
88
+ return list(value)
89
+
90
+ raise ValueError(f"Unknown type: {type_str!r}")
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Arg-like protocol
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ @runtime_checkable
99
+ class ArgLike(Protocol):
100
+ """Minimal interface shared by ``CommandArg`` and ``CustomCommandArg``."""
101
+
102
+ name: str
103
+ type: str
104
+ required: bool
105
+ default: str | None
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Kwargs coercion / validation
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def coerce_kwargs(
114
+ args: list[Any],
115
+ raw: dict[str, Any],
116
+ ) -> dict[str, Any]:
117
+ """Coerce and validate *raw* kwargs against *args* definitions.
118
+
119
+ Args:
120
+ args: Ordered list of arg descriptors (anything with ``.name``,
121
+ ``.type``, ``.required``, ``.default``).
122
+ raw: Raw keyword arguments from the caller / JSON body.
123
+
124
+ Returns:
125
+ A new dict with values converted to the declared types.
126
+
127
+ Raises:
128
+ ValueError: If a required argument is missing or an undeclared
129
+ argument is provided.
130
+ TypeError: If a value cannot be converted to the declared type.
131
+ """
132
+ declared_names: set[str] = {arg.name for arg in args}
133
+
134
+ # --- Reject undeclared arguments early (fail-fast) ---
135
+ undeclared = sorted(k for k in raw if k not in declared_names)
136
+ if undeclared:
137
+ raise ValueError(
138
+ f"Unexpected argument(s): {undeclared}; declared: {sorted(declared_names) or '(none)'}"
139
+ )
140
+
141
+ # --- Coerce declared arguments ---
142
+ coerced: dict[str, Any] = {}
143
+ for arg in args:
144
+ if arg.name in raw:
145
+ try:
146
+ coerced[arg.name] = parse_scalar(raw[arg.name], arg.type)
147
+ except (ValueError, TypeError) as exc:
148
+ raise TypeError(
149
+ f"Cannot convert argument {arg.name!r} to {arg.type}: {exc}"
150
+ ) from exc
151
+ elif arg.required:
152
+ raise ValueError(f"Required argument {arg.name!r} missing")
153
+ elif arg.default is not None:
154
+ if arg.type == "string_array":
155
+ try:
156
+ coerced[arg.name] = parse_scalar(json.loads(arg.default), arg.type)
157
+ except (ValueError, TypeError) as exc:
158
+ raise TypeError(
159
+ f"Invalid string_array default for argument {arg.name!r}"
160
+ ) from exc
161
+ continue
162
+ # Best-effort coercion of the default; fall back to the raw
163
+ # string if conversion fails (e.g. a placeholder like "N/A").
164
+ try:
165
+ coerced[arg.name] = parse_scalar(arg.default, arg.type)
166
+ except (ValueError, TypeError):
167
+ coerced[arg.name] = arg.default
168
+
169
+ return coerced
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1a1"
@@ -0,0 +1,66 @@
1
+ """Hosted coding-agent runtime for the sandbox HTTP server (opt-in ``agents`` group).
2
+
3
+ Public extension API::
4
+
5
+ from agent_sandbox.server.agents import AgentAdapter, register_adapter
6
+
7
+ See ``docs/en/design/agents-api.md``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from . import events
13
+ from .adapters import (
14
+ AdapterCapabilities,
15
+ AdapterRegistry,
16
+ AgentAdapter,
17
+ EventParser,
18
+ Invocation,
19
+ InvocationFile,
20
+ TurnContext,
21
+ default_adapters,
22
+ register_adapter,
23
+ )
24
+ from .config import (
25
+ AgentSpec,
26
+ GitSpec,
27
+ LimitsSpec,
28
+ McpServerSpec,
29
+ ProviderSpec,
30
+ WorkspaceSpec,
31
+ )
32
+ from .errors import AgentConfigError, AgentNotFoundError, AgentRuntimeError
33
+ from .events import ParsedEvent
34
+ from .redact import Redactor
35
+ from .runtime import AgentRuntime, Item, RuntimeEvent, Session, Turn
36
+ from .store import AgentStore
37
+
38
+ __all__ = [
39
+ "AdapterCapabilities",
40
+ "AdapterRegistry",
41
+ "AgentAdapter",
42
+ "AgentConfigError",
43
+ "AgentNotFoundError",
44
+ "AgentRuntime",
45
+ "AgentRuntimeError",
46
+ "AgentSpec",
47
+ "AgentStore",
48
+ "EventParser",
49
+ "GitSpec",
50
+ "Invocation",
51
+ "InvocationFile",
52
+ "Item",
53
+ "LimitsSpec",
54
+ "McpServerSpec",
55
+ "ParsedEvent",
56
+ "ProviderSpec",
57
+ "Redactor",
58
+ "RuntimeEvent",
59
+ "Session",
60
+ "Turn",
61
+ "TurnContext",
62
+ "WorkspaceSpec",
63
+ "default_adapters",
64
+ "events",
65
+ "register_adapter",
66
+ ]
@@ -0,0 +1,140 @@
1
+ """Adapter registry: built-ins, entry points and ``ABX_AGENTS_ADAPTER_MODULES``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import logging
7
+ import os
8
+ import threading
9
+
10
+ from .base import (
11
+ AdapterCapabilities,
12
+ AgentAdapter,
13
+ EventParser,
14
+ Invocation,
15
+ InvocationFile,
16
+ TurnContext,
17
+ )
18
+ from .claude_code import ClaudeCodeAdapter
19
+ from .codex import CodexAdapter
20
+ from .command import CommandAdapter
21
+ from .cursor import CursorAdapter
22
+ from .qwen_code import QwenCodeAdapter
23
+
24
+ __all__ = [
25
+ "AdapterCapabilities",
26
+ "AdapterRegistry",
27
+ "AgentAdapter",
28
+ "EventParser",
29
+ "Invocation",
30
+ "InvocationFile",
31
+ "TurnContext",
32
+ "default_adapters",
33
+ "register_adapter",
34
+ "ENTRY_POINT_GROUP",
35
+ "MODULES_ENV_VAR",
36
+ ]
37
+
38
+ ENTRY_POINT_GROUP = "agent_sandbox.agent_adapters"
39
+ MODULES_ENV_VAR = "ABX_AGENTS_ADAPTER_MODULES"
40
+
41
+ _log = logging.getLogger(__name__)
42
+
43
+
44
+ class AdapterRegistry:
45
+ """Name/alias → adapter class registry."""
46
+
47
+ def __init__(self, *, builtins: bool = True) -> None:
48
+ self._classes: dict[str, type[AgentAdapter]] = {}
49
+ self._aliases: dict[str, str] = {}
50
+ self._lock = threading.Lock()
51
+ if builtins:
52
+ for cls in (
53
+ CodexAdapter,
54
+ ClaudeCodeAdapter,
55
+ CursorAdapter,
56
+ QwenCodeAdapter,
57
+ CommandAdapter,
58
+ ):
59
+ self.register(cls)
60
+
61
+ def register(self, cls: type[AgentAdapter], *, override: bool = False) -> type[AgentAdapter]:
62
+ """Register *cls*; a name/alias clash is an error unless ``override=True``."""
63
+ name = getattr(cls, "name", None)
64
+ if not name or not isinstance(name, str):
65
+ raise ValueError(f"{cls!r} must define a non-empty class attribute 'name'")
66
+ with self._lock:
67
+ keys = (name, *cls.aliases)
68
+ if not override:
69
+ for key in keys:
70
+ owner = self._aliases.get(key) or (key if key in self._classes else None)
71
+ if owner is not None and owner != name:
72
+ raise ValueError(
73
+ f"adapter name/alias {key!r} is already registered by {owner!r}"
74
+ )
75
+ if name in self._classes:
76
+ raise ValueError(f"adapter {name!r} is already registered (use override=True)")
77
+ self._classes[name] = cls
78
+ for alias in cls.aliases:
79
+ self._aliases[alias] = name
80
+ return cls
81
+
82
+ def resolve_name(self, name: str) -> str | None:
83
+ """Canonical adapter name for *name* or an alias (``None`` if unknown)."""
84
+ key = name.strip().lower()
85
+ if key in self._classes:
86
+ return key
87
+ return self._aliases.get(key)
88
+
89
+ def get(self, name: str) -> AgentAdapter | None:
90
+ """A fresh adapter instance for *name* (or alias)."""
91
+ canonical = self.resolve_name(name)
92
+ return self._classes[canonical]() if canonical else None
93
+
94
+ def names(self) -> list[str]:
95
+ """Canonical adapter names."""
96
+ return sorted(self._classes)
97
+
98
+ def load_plugins(self) -> list[str]:
99
+ """Load entry-point and env-var adapters; returns the names loaded."""
100
+ loaded: list[str] = []
101
+ try:
102
+ from importlib.metadata import entry_points
103
+
104
+ for ep in entry_points(group=ENTRY_POINT_GROUP):
105
+ try:
106
+ cls = ep.load()
107
+ self.register(cls)
108
+ loaded.append(cls.name)
109
+ except Exception as exc: # plugin errors must not break the server
110
+ _log.warning("failed to load agent adapter entry point %s: %s", ep.name, exc)
111
+ except Exception as exc:
112
+ _log.debug("entry point discovery failed: %s", exc)
113
+ for module in filter(
114
+ None, (m.strip() for m in os.environ.get(MODULES_ENV_VAR, "").split(","))
115
+ ):
116
+ try:
117
+ importlib.import_module(module)
118
+ except Exception as exc:
119
+ _log.warning("failed to import agent adapter module %s: %s", module, exc)
120
+ return loaded
121
+
122
+
123
+ _default = AdapterRegistry()
124
+ _plugins_loaded = False
125
+ _plugins_lock = threading.Lock()
126
+
127
+
128
+ def default_adapters() -> AdapterRegistry:
129
+ """The process-wide registry (plugins are discovered on first use)."""
130
+ global _plugins_loaded
131
+ with _plugins_lock:
132
+ if not _plugins_loaded:
133
+ _plugins_loaded = True
134
+ _default.load_plugins()
135
+ return _default
136
+
137
+
138
+ def register_adapter(cls: type[AgentAdapter], *, override: bool = False) -> type[AgentAdapter]:
139
+ """Register *cls* in the default registry (usable as a class decorator)."""
140
+ return _default.register(cls, override=override)