intentframe-executor 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.
executor/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ IntentFrame Executor - A Standalone, Protocol-Driven Capability Service.
3
+
4
+ "The Hands" -- the only entity that touches the real world.
5
+
6
+ The Executor is an OS Capability Bridge that runs as a standalone,
7
+ process-isolated service. It communicates exclusively through validated
8
+ protocols (gRPC, REST, Unix socket) and secure channels.
9
+
10
+ Core Invariants:
11
+ 1. Fail-Closed: Any failure -> rejection, never silent approval
12
+ 2. Authorization Required: Every request must carry valid auth proof
13
+ 3. Credentials Never Leave: Creds stay in executor process memory
14
+ 4. Virtual Paths Only: Agents see virtual paths, never real paths
15
+
16
+ Architecture:
17
+ Transport Layer -> How requests arrive (pluggable)
18
+ Auth Layer -> How authorization is verified (pluggable)
19
+ Gateway -> Orchestration: verify -> validate -> route -> execute
20
+ Adapter Layer -> How capabilities are executed (pluggable per platform)
21
+ Services Layer -> Cross-cutting: audit, credentials, state, VFS
22
+ """
23
+
24
+ __version__ = "0.1.0"
@@ -0,0 +1,106 @@
1
+ """
2
+ Configuration loading and validation for the IntentFrame Executor.
3
+
4
+ The executor is fully config-driven. A single executor.yaml file
5
+ determines which transport, auth verifier, credential backend,
6
+ and adapter set to load at startup.
7
+
8
+ No code changes needed to switch deployment profiles.
9
+ Just change the config file.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ import yaml
17
+ from pydantic import ValidationError
18
+
19
+ from executor.config.schema import ExecutorConfig
20
+ from executor_sdk.constants import DEFAULT_CONFIG_FILENAME
21
+ from executor_sdk.exceptions import ConfigurationError
22
+
23
+ __all__ = ["ExecutorConfig", "load_config"]
24
+
25
+
26
+ def load_config(
27
+ config_path: str | Path | None = None,
28
+ config_dict: dict | None = None,
29
+ ) -> ExecutorConfig:
30
+ """Load and validate executor configuration.
31
+
32
+ Accepts either a file path to a YAML config or a pre-loaded dict.
33
+ The config is validated against the Pydantic schema and returned
34
+ as a typed ExecutorConfig object.
35
+
36
+ Args:
37
+ config_path: Path to executor.yaml. If None and config_dict is None,
38
+ searches default locations.
39
+ config_dict: Pre-loaded config dict (e.g., from tests). Takes
40
+ priority over config_path if both are provided.
41
+
42
+ Returns:
43
+ Validated ExecutorConfig.
44
+
45
+ Raises:
46
+ ConfigurationError: If the config file is missing, unreadable,
47
+ or fails schema validation.
48
+ """
49
+ if config_dict is not None:
50
+ raw = config_dict
51
+ elif config_path is not None:
52
+ raw = _load_yaml(Path(config_path))
53
+ else:
54
+ raw = _load_yaml(_find_default_config())
55
+
56
+ try:
57
+ return ExecutorConfig.model_validate(raw)
58
+ except ValidationError as exc:
59
+ raise ConfigurationError(
60
+ f"Invalid executor configuration: {exc.error_count()} validation errors",
61
+ details={"errors": exc.errors()},
62
+ ) from exc
63
+
64
+
65
+ def _load_yaml(path: Path) -> dict:
66
+ """Load a YAML file and return as dict."""
67
+ if not path.exists():
68
+ raise ConfigurationError(f"Config file not found: {path}")
69
+
70
+ try:
71
+ with open(path) as f:
72
+ data = yaml.safe_load(f)
73
+ except yaml.YAMLError as exc:
74
+ raise ConfigurationError(f"Invalid YAML in {path}: {exc}") from exc
75
+
76
+ if not isinstance(data, dict):
77
+ raise ConfigurationError(f"Config file must be a YAML mapping: {path}")
78
+
79
+ return data
80
+
81
+
82
+ def _find_default_config() -> Path:
83
+ """Search for executor.yaml in default locations.
84
+
85
+ Search order:
86
+ 1. Current working directory
87
+ 2. executor/config/ (relative to package)
88
+ 3. ~/.config/intentframe/
89
+
90
+ Returns the first path that exists.
91
+ Raises ConfigurationError if none found.
92
+ """
93
+ candidates = [
94
+ Path.cwd() / DEFAULT_CONFIG_FILENAME,
95
+ Path(__file__).parent / DEFAULT_CONFIG_FILENAME,
96
+ Path.home() / ".config" / "intentframe" / DEFAULT_CONFIG_FILENAME,
97
+ ]
98
+
99
+ for path in candidates:
100
+ if path.exists():
101
+ return path
102
+
103
+ locations = "\n ".join(str(p) for p in candidates)
104
+ raise ConfigurationError(
105
+ f"No {DEFAULT_CONFIG_FILENAME} found. Searched:\n {locations}"
106
+ )
@@ -0,0 +1,156 @@
1
+ # ═══════════════════════════════════════════════════════════════════════════════
2
+ # IntentFrame Executor Configuration
3
+ # ═══════════════════════════════════════════════════════════════════════════════
4
+ #
5
+ # This file configures the executor service for a specific deployment.
6
+ # Only three things change between deployments:
7
+ # 1. Transport protocol (how requests arrive)
8
+ # 2. Auth verification (how authorization is checked)
9
+ # 3. Credential backend (where secrets are stored)
10
+ #
11
+ # Everything else (gateway logic, adapter pattern, audit trail) is the same.
12
+ # ═══════════════════════════════════════════════════════════════════════════════
13
+
14
+ # ─── Executor packs: which implementations to load ───────────────────────────
15
+ # Packs are explicit and required -- the executor ships no built-in or
16
+ # platform-default packs. Each pack registers transport / auth / credential /
17
+ # storage / adapter implementations. List the packs your deployment needs, in
18
+ # order. An entry is either an importable module path exposing register_all(),
19
+ # or the short name of a pack advertised under the 'intentframe.executor_packs'
20
+ # entry-point group (how a third-party org plugs in its own pack).
21
+ #
22
+ # First-party packs available in this repo:
23
+ # intentframe_native_kit.intentframe_executor_pack_posix portable base (files VFS, sqlite, hmac, uds)
24
+ # intentframe_native_kit.intentframe_executor_pack_console console_user_io + simulated_user_io (headless)
25
+ # intentframe_native_kit.intentframe_executor_pack_macos native macOS adapters (+ portable base)
26
+ packs:
27
+ - intentframe_native_kit.intentframe_executor_pack_posix
28
+
29
+ # ─── Transport: How requests arrive ──────────────────────────────────────────
30
+ # Types: unix_socket (device default), grpc (cloud/device), rest (admin/debug)
31
+ transport:
32
+ type: unix_socket
33
+ options:
34
+ socket_path: /tmp/intentframe/executor.sock
35
+ # ── gRPC options (when type: grpc) ──
36
+ # host: "0.0.0.0"
37
+ # port: 50051
38
+ # ── REST options (when type: rest) ──
39
+ # host: "0.0.0.0"
40
+ # port: 8080
41
+
42
+ # ─── Auth: How authorization is verified ─────────────────────────────────────
43
+ # Types: guardian_hmac (IntentFrame default), mtls (cloud), bearer (admin/CI)
44
+ auth:
45
+ type: guardian_hmac
46
+ options: {}
47
+ # ── guardian_hmac options ──
48
+ # secret_key_ref: "intentframe/guardian-hmac-key"
49
+ # ── mtls options ──
50
+ # ca_cert: "/path/to/ca.pem"
51
+ # ── bearer options ──
52
+ # issuer: "https://auth.intentframe.com"
53
+ # audience: "executor"
54
+
55
+ # ─── Credentials: Where secrets are stored ───────────────────────────────────
56
+ # Backends: service (vault service over UDS, default), keyring (OS native),
57
+ # hashicorp (HashiCorp Vault KV v2, headless/cloud), env (dev/test ONLY)
58
+ credentials:
59
+ backend: service
60
+ options: {}
61
+ # ── keyring options (if backend: keyring) ──
62
+ # Uses OS-native keyring directly, bypassing the vault service.
63
+ #
64
+ # ── service options (default) ──
65
+ # Talks to the supervisor-managed vault over UDS.
66
+ # socket_path: "~/.intentframe/run/credential-vault.sock"
67
+ #
68
+ # ── hashicorp options (if backend: hashicorp) ──
69
+ # Stores secrets in HashiCorp Vault KV v2. Options override env vars
70
+ # (VAULT_ADDR, VAULT_TOKEN / VAULT_ROLE_ID+VAULT_SECRET_ID, etc.).
71
+ # addr: "https://vault.mycorp.com:8200"
72
+ # kv_mount: "secret"
73
+ # path_prefix: "intentframe"
74
+ # The vault SERVICE itself selects storage via IF_VAULT_BACKEND=hashicorp.
75
+
76
+ # ─── Worker Pool: Concurrency limits ────────────────────────────────────────
77
+ worker_pool:
78
+ max_workers: 4
79
+ default_timeout_seconds: 30.0
80
+
81
+ # ─── Adapters: Which capabilities to load ────────────────────────────────────
82
+ # Each ID must be registered by a platform-specific adapter module.
83
+ # Uncomment the adapters available on your platform.
84
+ adapters:
85
+ enabled: []
86
+ # ── macOS adapters ──
87
+ # - files
88
+ # - terminal
89
+ # - http_api
90
+ # - user_io
91
+ # - mail
92
+ # - calendar
93
+ # - contacts
94
+ # - notes
95
+ # - reminders
96
+ # - browser
97
+ # - messages
98
+ # - notifications
99
+ # - system
100
+ # - clipboard
101
+ # - shortcuts
102
+ # - spotlight
103
+ # - filesystem_watch
104
+
105
+ # ── Cloud adapters ──
106
+ # - s3
107
+ # - ses
108
+ # - lambda_invoke
109
+ # - cloud_storage
110
+
111
+ # ─── Executor pack options ──────────────────────────────────────────────────
112
+ # Opaque config slices owned and validated by executor packs.
113
+ pack_options:
114
+ # VFS mount table for the files adapter.
115
+ # List virtual-to-real path mappings here when the ``files`` adapter is enabled.
116
+ # files:
117
+ # base_path: null # base for relative real_path values; null = home dir
118
+ # mounts:
119
+ # - virtual_path: /home/
120
+ # real_path: ~/
121
+ # writable: true
122
+ # workspace_id: null # optional: resolve mounts from resource registry instead
123
+
124
+ # Real-path allowlist for host-file tools.
125
+ # Empty lists + no ``host_files`` adapter means "no host-file access".
126
+ host_files:
127
+ allowed_read_paths: []
128
+ allowed_write_paths: []
129
+
130
+ # Kernel-enforced sandbox for shell commands (macOS Seatbelt / sandbox-exec).
131
+ # Write scope is controlled by allowed_write_paths below (independent of VFS).
132
+ # All commands run under the highest-privilege template in allowed_templates.
133
+ # If the sandbox engine is unavailable, every RUN_COMMAND is rejected (fail-closed).
134
+ sandbox:
135
+ enabled: true
136
+ working_directory: ~/
137
+ allowed_write_paths:
138
+ - ~/
139
+ allowed_templates:
140
+ - pure_compute
141
+ - file_read_only
142
+ - file_read_write
143
+ # - network_outbound # uncomment to allow outbound network in sandboxed commands
144
+ # - network_full # uncomment to allow port binding
145
+ # - unrestricted # NOT recommended — bypasses most sandbox restrictions
146
+
147
+ # ─── Storage: Database and log paths ────────────────────────────────────────
148
+ # null = platform default (macOS: ~/Library/Application Support/IntentFrame/)
149
+ storage:
150
+ database_path: null
151
+ log_path: null
152
+
153
+ # ─── Logging ─────────────────────────────────────────────────────────────────
154
+ logging:
155
+ level: INFO
156
+ format: json # json (structured) or console (human-readable)
@@ -0,0 +1,223 @@
1
+ """
2
+ Pydantic schema for executor.yaml configuration.
3
+
4
+ Every configurable aspect of the executor is represented here.
5
+ The schema provides:
6
+ - Type validation (catch typos at startup, not at 3am)
7
+ - Default values (sensible defaults for common deployments)
8
+ - Documentation (field descriptions serve as config docs)
9
+
10
+ The config is intentionally flat where possible. Nesting is used
11
+ only for logical grouping (transport, auth, etc.), not for
12
+ hierarchy's sake.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from executor_sdk.constants import (
22
+ DEFAULT_ADAPTER_TIMEOUT,
23
+ DEFAULT_GRPC_PORT,
24
+ DEFAULT_MAX_WORKERS,
25
+ DEFAULT_REST_PORT,
26
+ DEFAULT_UNIX_SOCKET_PATH,
27
+ )
28
+
29
+ __all__ = [
30
+ "ExecutorConfig",
31
+ "TransportConfig",
32
+ "AuthConfig",
33
+ "CredentialConfig",
34
+ "WorkerPoolConfig",
35
+ "AdapterConfig",
36
+ "StorageConfig",
37
+ "LoggingConfig",
38
+ ]
39
+
40
+
41
+ class TransportConfig(BaseModel):
42
+ """Configuration for the transport layer.
43
+
44
+ Only ONE transport is active per executor instance.
45
+
46
+ Types:
47
+ unix_socket: Local IPC (device default)
48
+ grpc: Cross-machine, high-performance
49
+ rest: Admin tools, debugging, broad compatibility
50
+ """
51
+
52
+ model_config = ConfigDict(extra="forbid")
53
+
54
+ type: str = Field(
55
+ default="unix_socket",
56
+ description="Transport type: unix_socket, grpc, rest",
57
+ )
58
+ options: dict[str, Any] = Field(
59
+ default_factory=lambda: {"socket_path": DEFAULT_UNIX_SOCKET_PATH},
60
+ description="Transport-specific configuration options",
61
+ )
62
+
63
+
64
+ class AuthConfig(BaseModel):
65
+ """Configuration for authorization verification.
66
+
67
+ Only ONE auth verifier is active per executor instance.
68
+
69
+ Types:
70
+ guardian_hmac: IntentFrame default (HMAC signature from Guardian)
71
+ mtls: Mutual TLS certificate verification (cloud)
72
+ bearer: JWT or opaque bearer token (admin/CI)
73
+ """
74
+
75
+ model_config = ConfigDict(extra="forbid")
76
+
77
+ type: str = Field(
78
+ default="guardian_hmac",
79
+ description="Auth verifier type: guardian_hmac, mtls, bearer",
80
+ )
81
+ options: dict[str, Any] = Field(
82
+ default_factory=dict,
83
+ description="Auth-specific configuration options",
84
+ )
85
+
86
+
87
+ class CredentialConfig(BaseModel):
88
+ """Configuration for the credential vault backend.
89
+
90
+ Backends:
91
+ service: Vault service over UDS (default — uses the supervisor-managed vault)
92
+ keyring: OS native keyring directly (macOS Keychain, Windows Credential Locker)
93
+ hashicorp: HashiCorp Vault KV v2 over HTTP (headless / cloud / on-prem)
94
+ env: Environment variables (development/testing ONLY)
95
+ """
96
+
97
+ model_config = ConfigDict(extra="forbid")
98
+
99
+ backend: str = Field(
100
+ default="service",
101
+ description="Credential backend: service, keyring, hashicorp, env",
102
+ )
103
+ options: dict[str, Any] = Field(
104
+ default_factory=dict,
105
+ description="Backend-specific configuration options",
106
+ )
107
+
108
+
109
+ class WorkerPoolConfig(BaseModel):
110
+ """Configuration for the capability worker pool."""
111
+
112
+ model_config = ConfigDict(extra="forbid")
113
+
114
+ max_workers: int = Field(
115
+ default=DEFAULT_MAX_WORKERS,
116
+ ge=1,
117
+ le=32,
118
+ description="Maximum concurrent adapter executions",
119
+ )
120
+ default_timeout_seconds: float = Field(
121
+ default=DEFAULT_ADAPTER_TIMEOUT,
122
+ gt=0,
123
+ description="Default timeout per adapter execution (seconds)",
124
+ )
125
+
126
+
127
+ class AdapterConfig(BaseModel):
128
+ """Configuration for capability adapters.
129
+
130
+ The enabled list determines which adapters are loaded at startup.
131
+ Each adapter ID must be registered via register_adapter() by
132
+ a platform-specific module.
133
+ """
134
+
135
+ model_config = ConfigDict(extra="forbid")
136
+
137
+ enabled: list[str] = Field(
138
+ default_factory=list,
139
+ description="List of adapter IDs to load at startup",
140
+ )
141
+
142
+
143
+ class StorageConfig(BaseModel):
144
+ """Configuration for database, log file paths, and backend selection.
145
+
146
+ If paths are null/None, platform-specific defaults are used:
147
+ macOS: ~/Library/Application Support/IntentFrame/
148
+ Linux: ~/.local/share/intentframe/
149
+ Cloud: /var/lib/intentframe/
150
+ """
151
+
152
+ model_config = ConfigDict(extra="forbid")
153
+
154
+ audit_backend: str = Field(
155
+ default="sqlite",
156
+ description="Audit log backend: sqlite, cloud",
157
+ )
158
+ state_backend: str = Field(
159
+ default="sqlite",
160
+ description="State store backend: sqlite, redis",
161
+ )
162
+ database_path: str | None = Field(
163
+ default=None,
164
+ description="Path to SQLite database. None = platform default.",
165
+ )
166
+ log_path: str | None = Field(
167
+ default=None,
168
+ description="Path to log file. None = platform default.",
169
+ )
170
+
171
+
172
+ class LoggingConfig(BaseModel):
173
+ """Configuration for structured logging."""
174
+
175
+ model_config = ConfigDict(extra="forbid")
176
+
177
+ level: str = Field(
178
+ default="INFO",
179
+ description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL",
180
+ )
181
+ format: str = Field(
182
+ default="json",
183
+ description="Log format: json (structured) or console (human-readable)",
184
+ )
185
+
186
+
187
+ class ExecutorConfig(BaseModel):
188
+ """Root configuration schema for the IntentFrame Executor.
189
+
190
+ Loaded from executor.yaml and validated at startup.
191
+ Any validation error prevents the executor from starting (fail-closed).
192
+ """
193
+
194
+ model_config = ConfigDict(extra="forbid")
195
+
196
+ packs: list[str] = Field(
197
+ default_factory=list,
198
+ description=(
199
+ "Executor packs to load at startup, in order (required -- there are "
200
+ "no built-in or platform-default packs). Each entry is either an "
201
+ "importable module path exposing register_all() (e.g. "
202
+ "'intentframe_native_kit.intentframe_executor_pack_posix'), or the short name of a pack "
203
+ "advertised via the 'intentframe.executor_packs' entry-point group "
204
+ "(e.g. an external org's installed pack). Packs register the "
205
+ "transport, auth, credential, storage and adapter implementations "
206
+ "this executor uses, so at least one base pack is required."
207
+ ),
208
+ )
209
+ transport: TransportConfig = Field(default_factory=TransportConfig)
210
+ auth: AuthConfig = Field(default_factory=AuthConfig)
211
+ credentials: CredentialConfig = Field(default_factory=CredentialConfig)
212
+ worker_pool: WorkerPoolConfig = Field(default_factory=WorkerPoolConfig)
213
+ adapters: AdapterConfig = Field(default_factory=AdapterConfig)
214
+ pack_options: dict[str, dict[str, Any]] = Field(
215
+ default_factory=dict,
216
+ description=(
217
+ "Opaque executor-pack/adaptor options keyed by pack-owned feature "
218
+ "or adapter ID. Core validates only that each slice is a mapping; "
219
+ "packs own their own schema validation."
220
+ ),
221
+ )
222
+ storage: StorageConfig = Field(default_factory=StorageConfig)
223
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
executor/dispatch.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ Action dispatcher -- routes action types to capability adapters.
3
+
4
+ The dispatcher maintains a registry of action_type -> CapabilityAdapter
5
+ mappings. When the gateway receives an ExecutionRequest, it asks the
6
+ dispatcher to resolve the action_type to the correct adapter.
7
+
8
+ Design:
9
+ - Simple dict-based dispatch. No magic, no reflection.
10
+ - Each action type maps to exactly ONE adapter.
11
+ - Action types must be globally unique across all registered adapters.
12
+ - Unknown action types cause immediate rejection (fail-closed).
13
+ - Adapters register their supported actions at startup.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+
20
+ from executor_sdk.adapters.base import CapabilityAdapter
21
+ from executor_sdk.exceptions import AdapterNotFoundError
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ __all__ = ["ActionDispatcher"]
26
+
27
+
28
+ class ActionDispatcher:
29
+ """Routes action types to their registered capability adapters.
30
+
31
+ Usage:
32
+ dispatcher = ActionDispatcher()
33
+ dispatcher.register(mail_adapter)
34
+ dispatcher.register(files_adapter)
35
+
36
+ adapter = dispatcher.resolve("SEND_EMAIL")
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._action_to_adapter: dict[str, CapabilityAdapter] = {}
41
+ self._adapter_registry: dict[str, CapabilityAdapter] = {}
42
+
43
+ def register(self, adapter: CapabilityAdapter) -> None:
44
+ """Register an adapter and all its supported actions.
45
+
46
+ Each action type in adapter.supported_actions() is mapped to
47
+ this adapter. Duplicate action types raise ValueError to catch
48
+ configuration errors at startup (not at runtime).
49
+
50
+ Args:
51
+ adapter: The adapter instance to register.
52
+
53
+ Raises:
54
+ ValueError: If any action type is already registered
55
+ to a different adapter.
56
+ """
57
+ manifest = adapter.manifest()
58
+ adapter_id = manifest.adapter_id
59
+
60
+ for action in adapter.supported_actions():
61
+ existing = self._action_to_adapter.get(action)
62
+ if existing is not None:
63
+ existing_id = existing.manifest().adapter_id
64
+ if existing_id != adapter_id:
65
+ raise ValueError(
66
+ f"Action '{action}' is already registered to adapter "
67
+ f"'{existing_id}'. Cannot register to '{adapter_id}'. "
68
+ f"Each action type must map to exactly one adapter."
69
+ )
70
+
71
+ self._action_to_adapter[action] = adapter
72
+
73
+ self._adapter_registry[adapter_id] = adapter
74
+
75
+ logger.info(
76
+ "Registered adapter: id=%s actions=%s",
77
+ adapter_id,
78
+ adapter.supported_actions(),
79
+ )
80
+
81
+ def resolve(self, action_type: str) -> CapabilityAdapter:
82
+ """Resolve an action type to its registered adapter.
83
+
84
+ Args:
85
+ action_type: The action to look up (e.g., "SEND_EMAIL").
86
+
87
+ Returns:
88
+ The CapabilityAdapter registered for this action.
89
+
90
+ Raises:
91
+ AdapterNotFoundError: If no adapter handles this action type.
92
+ """
93
+ adapter = self._action_to_adapter.get(action_type)
94
+ if adapter is None:
95
+ registered = ", ".join(sorted(self._action_to_adapter)) or "(none)"
96
+ raise AdapterNotFoundError(
97
+ f"No adapter registered for action: '{action_type}'. "
98
+ f"Registered actions: {registered}",
99
+ )
100
+ return adapter
101
+
102
+ def get_adapter(self, adapter_id: str) -> CapabilityAdapter | None:
103
+ """Look up an adapter by its ID. Returns None if not found."""
104
+ return self._adapter_registry.get(adapter_id)
105
+
106
+ @property
107
+ def registered_actions(self) -> list[str]:
108
+ """All action types that have a registered adapter."""
109
+ return sorted(self._action_to_adapter.keys())
110
+
111
+ @property
112
+ def registered_adapters(self) -> list[str]:
113
+ """All registered adapter IDs."""
114
+ return sorted(self._adapter_registry.keys())
115
+
116
+ def __contains__(self, action_type: str) -> bool:
117
+ """Check if an action type is registered."""
118
+ return action_type in self._action_to_adapter
119
+
120
+ def __len__(self) -> int:
121
+ """Number of registered action types."""
122
+ return len(self._action_to_adapter)