diff --git a/agent/terminal_env_provider.py b/agent/terminal_env_provider.py index 3a862108fd..fc8d58fdcd 100644 --- a/agent/terminal_env_provider.py +++ b/agent/terminal_env_provider.py @@ -1,43 +1,12 @@ -""" -Terminal Environment Provider ABC -================================= +"""Terminal Environment Provider ABC. -Pluggable-backend interface for terminal execution environments (cloud -sandboxes, remote runners). Providers register via -:meth:`PluginContext.register_terminal_environment_provider`; -:func:`tools.terminal_tool._create_environment` consults the registry for any -``TERMINAL_ENV`` / ``terminal.backend`` value that is not a built-in backend. -Built-ins stay in-tree under ``tools/environments/``; this extension point -exists so third-party sandbox vendors do NOT have to live in core. - -Classification contract ------------------------ -A backend participates in core policy decisions that were historically -frozensets of built-in names. Each is a declarative attribute so a new backend -cannot silently miss a classification site: - -* ``is_remote`` — commands run off-host: suppresses host OS/home/cwd hints in - the system prompt, the host Python probe, and remote-aware skill env handling. -* ``is_container`` — own filesystem rooted away from the host: container - resource config is passed through, host-looking cwds are sanitized, file - tools use container path resolution. -* ``skip_container_guards`` — sandbox is disposable enough to skip - dangerous-command approval prompts. Defaults to ``is_container``; backends - that can mount host paths should override to ``False``. -* ``cache_path_base`` — where auto-synced ``~/.hermes/cache`` files land inside - the backend (``"~/.hermes"``, ``"/root/.hermes"``), or ``None`` when host - paths remain correct. -* ``strip_env_keys`` — vendor credential env vars, stripped from every - subprocess the agent spawns so a model-authored command can never read them. -* ``session_isolated_when_nonpersistent`` — non-persistent mode gives each - session its own sandbox identity; opt in when a shared name would let two - ephemeral runs attach to and destroy each other's sandbox. - -:meth:`create_environment` returns any object satisfying the -:class:`tools.environments.base.BaseEnvironment` duck-typed interface -(``execute()``, ``cleanup()`` …); the registry does not isinstance-check it. -The factory stamps ``_hermes_backend_name`` on the result so file-path -resolution can identify plugin backends without class-name sniffing. +Pluggable-backend interface for terminal execution environments (cloud sandboxes, remote +runners). Providers register via :meth:`PluginContext.register_terminal_environment_provider`; +:func:`tools.terminal_tool._create_environment` consults the registry for any ``TERMINAL_ENV`` +/ ``terminal.backend`` value that is not a built-in (built-ins stay in ``tools/environments/``; +third-party sandbox vendors do NOT have to live in core). :meth:`create_environment` returns +any ``BaseEnvironment`` duck type (``execute()``, ``cleanup()`` …); the factory stamps +``_hermes_backend_name`` on the result so file-path resolution can identify plugin backends. """ from __future__ import annotations @@ -51,8 +20,24 @@ from agent.provider_base import ProviderBase class TerminalEnvironmentProvider(ProviderBase): """Abstract base class for a pluggable terminal execution backend. - :attr:`name` is the ``terminal.backend`` / ``TERMINAL_ENV`` value - (``[a-z0-9_]``); the registry rejects built-in backend names. + :attr:`name` is the ``terminal.backend`` / ``TERMINAL_ENV`` value (``[a-z0-9_]``); the + registry rejects built-in backend names. + + Classification contract — each core policy decision that was historically a frozenset of + built-in names is a declarative attribute so a new backend cannot silently miss one: + + * ``is_remote`` — commands run off-host: suppresses host OS/home/cwd hints in the system + prompt, the host Python probe, and remote-aware skill env handling. + * ``is_container`` — own filesystem rooted away from the host: container resource config + passed through, host-looking cwds sanitized, file tools use container path resolution. + * ``skip_container_guards`` — sandbox disposable enough to skip dangerous-command approval + prompts. Defaults to ``is_container``; backends that can mount host paths override to False. + * ``cache_path_base`` — where auto-synced ``~/.hermes/cache`` files land inside the backend + (``"~/.hermes"``, ``"/root/.hermes"``), or ``None`` when host paths remain correct. + * ``strip_env_keys`` — vendor credential env vars, stripped from every subprocess the agent + spawns so a model-authored command can never read them. + * ``session_isolated_when_nonpersistent`` — non-persistent mode gives each session its own + sandbox identity; opt in when a shared name would let two ephemeral runs destroy each other. """ @property @@ -60,31 +45,26 @@ class TerminalEnvironmentProvider(ProviderBase): """One-line description shown in backend pickers.""" return f"Run commands in a {self.display_name} environment." - # -- Classification flags (see module docstring) ----------------------- - is_remote: bool = True is_container: bool = True session_isolated_when_nonpersistent: bool = False @property def skip_container_guards(self) -> bool: - """Whether dangerous-command approval prompts are skipped.""" return self.is_container @property def cache_path_base(self) -> Optional[str]: - """Base dir for synced Hermes cache files inside the backend.""" return None @property def strip_env_keys(self) -> frozenset: - """Backend-owned credential env var names to strip from subprocesses.""" return frozenset() @property def env_description(self) -> str: - """Prompt-builder fallback for where commands run when the live backend - probe fails at system-prompt build time (e.g. ``"a Daytona workspace (Linux)"``).""" + """Prompt-builder fallback for where commands run when the live backend probe fails + at system-prompt build time (e.g. ``"a Daytona workspace (Linux)"``).""" return f"a {self.display_name} environment (likely Linux)" # -- Availability / setup UX ------------------------------------------- @@ -95,9 +75,8 @@ class TerminalEnvironmentProvider(ProviderBase): NOT make network calls; runs during requirement checks and UI paints.""" def check_requirements(self, config: Dict[str, Any]) -> bool: - """Full requirements check for :func:`check_terminal_requirements` with the - merged terminal env config. Default defers to :meth:`is_available`; log - actionable errors before returning False.""" + """Full requirements check for :func:`check_terminal_requirements` with the merged + terminal env config; log actionable errors before returning False.""" return self.is_available() def probe(self) -> Tuple[str, str]: @@ -108,18 +87,15 @@ class TerminalEnvironmentProvider(ProviderBase): return ("needs_setup", f"{self.display_name} is not configured.") def setup_instructions(self) -> List[str]: - """Lines printed by ``hermes setup`` after this backend is selected. The - wizard persists ``terminal.backend`` itself; interactive flows go in - :meth:`post_setup`.""" + """Lines printed by ``hermes setup`` after this backend is selected. The wizard + persists ``terminal.backend`` itself; interactive flows go in :meth:`post_setup`.""" return [] def post_setup(self) -> None: - """Optional interactive hook run by ``hermes setup`` after selection - (prompt for tokens, install SDKs). Default no-op.""" + """Optional interactive hook run by ``hermes setup`` after selection (prompt for tokens, install SDKs).""" def doctor_checks(self) -> List[Tuple[bool, str, str]]: """``hermes doctor`` rows ``(ok, label, detail)``; default reflects :meth:`is_available`.""" - ok = False try: ok = bool(self.is_available()) except Exception: @@ -142,9 +118,8 @@ class TerminalEnvironmentProvider(ProviderBase): ): """Create and return an execution environment (``BaseEnvironment`` duck type). - MUST accept ``**kwargs`` and ignore unknown keys so the factory signature - can evolve without breaking older plugins. ``task_id`` keys environment - reuse/persistence; ``container_config`` carries ``container_cpu`` / - ``container_memory`` / ``container_disk`` / ``container_persistent`` when - :attr:`is_container` is True. + MUST accept ``**kwargs`` and ignore unknown keys so the factory signature can evolve + without breaking older plugins. ``task_id`` keys environment reuse/persistence; + ``container_config`` carries ``container_cpu`` / ``container_memory`` / + ``container_disk`` / ``container_persistent`` when :attr:`is_container` is True. """ diff --git a/agent/terminal_env_registry.py b/agent/terminal_env_registry.py index 251f84f28c..7e4fe6eb75 100644 --- a/agent/terminal_env_registry.py +++ b/agent/terminal_env_registry.py @@ -1,26 +1,13 @@ -""" -Terminal Environment Registry -============================= +"""Terminal Environment Registry. -Central map of registered pluggable terminal backends. Populated by plugins -at load time via :meth:`PluginContext.register_terminal_environment_provider`; -consumed by :func:`tools.terminal_tool._create_environment` and the -classification helpers spread across the terminal/file/approval/prompt -surfaces. - -Unlike the image/video/web/browser registries there is **no active-provider -resolution here**: the active backend is whatever ``TERMINAL_ENV`` / -``terminal.backend`` names, exactly as for built-in backends. The registry's -only job is mapping that name to a provider instance (and answering the -classification questions the core historically answered with frozensets of -built-in names). - -Built-in backend names are reserved — :func:`register_provider` rejects a -provider whose ``name`` collides with one, so a plugin can never shadow the -in-tree docker/modal/... implementations. - -Mirrors :mod:`agent.browser_registry` scope semantics: providers register -into a per-profile scope (multiplexed gateways) or the global base map. +Central map of registered pluggable terminal backends, populated by plugins via +:meth:`PluginContext.register_terminal_environment_provider` and consumed by +:func:`tools.terminal_tool._create_environment` plus the classification helpers across the +terminal/file/approval/prompt surfaces. Unlike the image/video/web/browser registries there +is **no active-provider resolution**: the active backend is whatever ``TERMINAL_ENV`` / +``terminal.backend`` names. Built-in names are reserved (registration raises) so a plugin can +never shadow the in-tree docker/modal/... implementations. Scope semantics mirror +:mod:`agent.browser_registry` (per-profile scope or the global base map). """ from __future__ import annotations @@ -68,9 +55,8 @@ def plugin_backend_names(*, scope: Optional[str] = None) -> List[str]: def provider_flag(name: str, attr: str, default=False): """Read a classification attribute off the provider for *name*. - Fail-soft: unknown backend or a raising property returns *default* so a - misbehaving plugin degrades to built-in-equivalent behavior instead of - taking the terminal tool down. + Fail-soft: unknown backend or a raising property returns *default* so a misbehaving + plugin degrades to built-in-equivalent behavior instead of taking the terminal tool down. """ provider = _registry.get_provider(name) if provider is None: @@ -86,13 +72,9 @@ def provider_flag(name: str, attr: str, default=False): def plugin_strip_env_keys() -> frozenset: - """Union of every registered provider's ``strip_env_keys``. - - Secrets are stripped for ALL registered backends, not just the active - one — a token in the process environment is strippable regardless of - which backend is selected (mirrors how MODAL_*/DAYTONA_API_KEY sit in - the static tier-1 set unconditionally). - """ + """Union of every registered provider's ``strip_env_keys`` — across ALL scopes, not + just the active backend: a token in the process environment is strippable regardless of + which backend is selected (as MODAL_*/DAYTONA_API_KEY sit in the static tier-1 set).""" keys: set = set() with _registry._lock: all_providers = list(_registry._providers.values()) diff --git a/agent/transcription_provider.py b/agent/transcription_provider.py index 80c32f07e5..3dd3490f6b 100644 --- a/agent/transcription_provider.py +++ b/agent/transcription_provider.py @@ -1,34 +1,20 @@ -""" -Transcription Provider ABC -========================== +"""Transcription Provider ABC — pluggable speech-to-text backends. -Pluggable-backend interface for speech-to-text. Providers register via -:meth:`PluginContext.register_transcription_provider`; the one named by -``stt.provider`` services :func:`tools.transcription_tools.transcribe_audio` -**when that name is not a built-in**. Built-ins (``BUILTIN_STT_PROVIDERS`` in -:mod:`tools.transcription_tools`) always win: the registry rejects colliding -names at registration and the dispatcher re-checks at dispatch time. The -``HERMES_LOCAL_STT_COMMAND`` shell escape hatch stays on the built-in -``local_command`` path. - -Response contract for :meth:`TranscriptionProvider.transcribe`:: - - success bool - transcript str transcribed text (empty when success=False) - provider str provider name (for diagnostics) - error str only when success=False +Providers register via :meth:`PluginContext.register_transcription_provider`; the one named +by ``stt.provider`` services :func:`tools.transcription_tools.transcribe_audio` **when that +name is not a built-in** (built-ins always win; ``HERMES_LOCAL_STT_COMMAND`` stays on the +built-in ``local_command`` path). :meth:`TranscriptionProvider.transcribe` envelope: +``success`` bool, ``transcript`` str (empty on failure), ``provider`` str, ``error`` str +(only when success=False). """ from __future__ import annotations import abc -import logging from typing import Any, Dict, Optional from agent.provider_base import CatalogProviderBase -logger = logging.getLogger(__name__) - class TranscriptionProvider(CatalogProviderBase): """Abstract base class for a speech-to-text backend. @@ -46,13 +32,10 @@ class TranscriptionProvider(CatalogProviderBase): language: Optional[str] = None, **extra: Any, ) -> Dict[str, Any]: - """Transcribe the audio file at ``file_path`` into the module-docstring envelope. + """Transcribe ``file_path`` (existence + size already validated) into the module envelope. - Implementations should NOT raise — convert exceptions to the error - envelope so the gateway/CLI caller always gets a consistent shape. The - dispatcher has already validated existence + size. ``model`` None → - :meth:`default_model`; ``language`` is an optional BCP-47 hint. The - dispatcher forwards ``prompt`` in ``extra`` when ``stt.prompt`` or a - ``pre_transcription`` hook sets one — prompt-capable providers may use - it as a vocabulary hint; unknown keys must be ignored. + Must NOT raise — convert exceptions to the error envelope. ``model`` None → + :meth:`default_model`; ``language`` is an optional BCP-47 hint; ``extra`` may carry + ``prompt`` (from ``stt.prompt`` or a ``pre_transcription`` hook) as a vocabulary hint; + unknown keys must be ignored. """ diff --git a/agent/transcription_registry.py b/agent/transcription_registry.py index 99e6b4a2b8..11a0919dd9 100644 --- a/agent/transcription_registry.py +++ b/agent/transcription_registry.py @@ -1,16 +1,10 @@ -""" -Transcription Provider Registry -================================ +"""Transcription Provider Registry. -Central map of registered STT providers. Populated by plugins at import-time -via :meth:`PluginContext.register_transcription_provider`; consumed by -:mod:`tools.transcription_tools` to dispatch :func:`transcribe_audio` calls -to the active plugin backend **when** the configured ``stt.provider`` name is -not a built-in. - -Built-ins-always-win: a plugin name colliding with a built-in STT provider is -rejected at registration with a warning (re-checked at dispatch time in -:func:`tools.transcription_tools._dispatch_to_plugin_provider`). +Central map of registered STT providers, populated by plugins via +:meth:`PluginContext.register_transcription_provider` and consumed by +:mod:`tools.transcription_tools` to dispatch :func:`transcribe_audio` to the active plugin +backend **when** ``stt.provider`` is not a built-in. Built-ins always win: a colliding name is +rejected at registration with a warning (re-checked at dispatch time). """ from __future__ import annotations @@ -23,19 +17,11 @@ from agent.transcription_provider import TranscriptionProvider logger = logging.getLogger(__name__) -# Names reserved for native built-in STT handlers. **Kept in sync with -# ``BUILTIN_STT_PROVIDERS`` in :mod:`tools.transcription_tools`** (a regression -# test in ``tests/agent/test_transcription_registry.py::TestBuiltinSync`` fails -# on drift); importing it directly would be a circular import. +# Native built-in STT handlers. **Kept in sync with ``BUILTIN_STT_PROVIDERS`` in +# :mod:`tools.transcription_tools`** (TestBuiltinSync fails on drift); importing it +# directly would be a circular import. _BUILTIN_NAMES = frozenset({ - "local", - "local_command", - "groq", - "openai", - "mistral", - "xai", - "elevenlabs", - "deepinfra", + "local", "local_command", "groq", "openai", "mistral", "xai", "elevenlabs", "deepinfra", }) @@ -48,8 +34,7 @@ def _warn_builtin_collision(key: str) -> None: ) -# Case-insensitive, whitespace-tolerant keys mirror how -# ``tools.transcription_tools`` normalizes the configured ``stt.provider``. +# Case-insensitive, whitespace-tolerant keys mirror ``tools.transcription_tools``. _registry: ProviderRegistry[TranscriptionProvider] = ProviderRegistry( label="Transcription", provider_cls=TranscriptionProvider,