diff --git a/tests/tools/test_daytona_environment.py b/tests/tools/test_daytona_environment.py index b34dade3d81..09e0240eb35 100644 --- a/tests/tools/test_daytona_environment.py +++ b/tests/tools/test_daytona_environment.py @@ -428,12 +428,20 @@ class TestSdkError: class TestEnsureSandboxReady: def test_restarts_stopped_sandbox(self, make_env): env = make_env() + env._needs_refresh = True env._sandbox.state = "stopped" env._ensure_sandbox_ready() env._sandbox.start.assert_called() def test_no_restart_when_running(self, make_env): env = make_env() + env._needs_refresh = True env._sandbox.state = "started" env._ensure_sandbox_ready() env._sandbox.start.assert_not_called() + + def test_skips_refresh_when_not_needed(self, make_env): + env = make_env() + env._needs_refresh = False + env._ensure_sandbox_ready() + env._sandbox.refresh_data.assert_not_called() diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index ce98217cf85..c2776bb2cdf 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -239,10 +239,14 @@ def _make_execute_only_env(forward_env=None): env = docker_env.DockerEnvironment.__new__(docker_env.DockerEnvironment) env.cwd = "/root" env.timeout = 60 + env.env = {} env._forward_env = forward_env or [] env._env = {} env._prepare_command = lambda command: (command, None) - env._timeout_result = lambda timeout: {"output": f"timed out after {timeout}", "returncode": 124} + env._snapshot_path = None + env._snapshot_ready = False + env._session_id = "" + env._cached_forward_env_args = None env._container_id = "test-container" env._docker_exe = "/usr/bin/docker" return env diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py index e94c0c4f902..6e30aa2ceac 100644 --- a/tools/environments/daytona.py +++ b/tools/environments/daytona.py @@ -9,6 +9,7 @@ import logging import math import shlex import threading +import time as _time import warnings from pathlib import Path from typing import Dict, Optional @@ -17,6 +18,8 @@ from tools.environments.base import BaseEnvironment logger = logging.getLogger(__name__) +_SYNC_INTERVAL_SECONDS = 5.0 + class DaytonaEnvironment(BaseEnvironment): """Daytona cloud sandbox execution backend. @@ -113,6 +116,10 @@ class DaytonaEnvironment(BaseEnvironment): logger.info("Daytona: created sandbox %s for task %s", self._sandbox.id, task_id) + # The sandbox is freshly created/started — no need to refresh yet. + self._needs_refresh = False + self._last_sync_time: float = 0.0 + # Detect remote home dir first so mounts go to the right place. self._remote_home = "/root" try: @@ -178,10 +185,13 @@ class DaytonaEnvironment(BaseEnvironment): def _ensure_sandbox_ready(self): """Restart sandbox if it was stopped (e.g., by a previous interrupt).""" + if not self._needs_refresh: + return self._sandbox.refresh_data() if self._sandbox.state in (self._SandboxState.STOPPED, self._SandboxState.ARCHIVED): self._sandbox.start() logger.info("Daytona: restarted sandbox %s", self._sandbox.id) + self._needs_refresh = False # ------------------------------------------------------------------ # Unified execution hooks @@ -191,7 +201,10 @@ class DaytonaEnvironment(BaseEnvironment): """Ensure sandbox is ready and sync credentials before each command.""" with self._lock: self._ensure_sandbox_ready() - self._sync_skills_and_credentials() + now = _time.monotonic() + if now - self._last_sync_time >= _SYNC_INTERVAL_SECONDS: + self._sync_skills_and_credentials() + self._last_sync_time = now def _run_bash(self, cmd_string: str, *, timeout: int | None = None, stdin_data: str | None = None): @@ -229,6 +242,7 @@ class DaytonaEnvironment(BaseEnvironment): with self._lock: if self._sandbox is None: return + self._needs_refresh = True try: if self._persistent: self._sandbox.stop() diff --git a/tools/environments/docker.py b/tools/environments/docker.py index be66e228c22..00767d0a4d2 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -218,6 +218,7 @@ class DockerEnvironment(BaseEnvironment): self._persistent = persistent_filesystem self._task_id = task_id self._forward_env = _normalize_forward_env_names(forward_env) + self._cached_forward_env_args: list[str] | None = None self._container_id: Optional[str] = None logger.info(f"DockerEnvironment volumes: {volumes}") # Ensure volumes is a list (config.yaml could be malformed) @@ -424,7 +425,13 @@ class DockerEnvironment(BaseEnvironment): ``env_passthrough`` vars so skills that declare ``required_environment_variables`` (e.g. Notion) have their keys forwarded into the container automatically. + + Result is cached at instance level to avoid re-reading ~/.hermes/.env + and rebuilding the arg list on every command. """ + if self._cached_forward_env_args is not None: + return self._cached_forward_env_args + forward_keys = set(self._forward_env) try: from tools.env_passthrough import get_all_passthrough @@ -440,9 +447,11 @@ class DockerEnvironment(BaseEnvironment): value = hermes_env.get(key) if value is not None: args.extend(["-e", f"{key}={value}"]) + self._cached_forward_env_args = args return args def _run_bash(self, cmd_string: str, *, + timeout: int | None = None, stdin_data: str | None = None) -> subprocess.Popen: """Spawn ``bash -c `` inside the Docker container.""" assert self._container_id, "Container not started" @@ -465,7 +474,8 @@ class DockerEnvironment(BaseEnvironment): pass return proc - def _run_bash_login(self, cmd_string: str) -> subprocess.Popen: + def _run_bash_login(self, cmd_string: str, *, + timeout: int | None = None) -> subprocess.Popen: """Spawn ``bash -l -c `` for snapshot creation.""" assert self._container_id, "Container not started" cmd = [self._docker_exe, "exec", self._container_id, @@ -479,9 +489,9 @@ class DockerEnvironment(BaseEnvironment): def cleanup(self): """Stop and remove the container. Bind-mount dirs persist if persistent=True.""" if self._container_id: - # Clean up snapshot and cwdfile inside the container + # Clean up snapshot inside the container paths_to_rm = " ".join( - p for p in (self._snapshot_path, self._cwdfile_path) if p + p for p in (self._snapshot_path,) if p ) if paths_to_rm: try: diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 93cf411586b..19a799260f5 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -10,6 +10,7 @@ import logging import re import shlex import threading +import time as _time from pathlib import Path from typing import Any, Dict, Optional @@ -18,6 +19,7 @@ from tools.environments.base import BaseEnvironment logger = logging.getLogger(__name__) +_SYNC_INTERVAL_SECONDS = 5.0 _SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json" _DIRECT_SNAPSHOT_NAMESPACE = "direct" @@ -179,6 +181,7 @@ class ModalEnvironment(BaseEnvironment): self._app = None self._worker = _AsyncWorker() self._synced_files: Dict[str, tuple] = {} + self._last_sync_time: float = 0.0 sandbox_kwargs = dict(modal_sandbox_kwargs or {}) @@ -347,7 +350,10 @@ class ModalEnvironment(BaseEnvironment): # ------------------------------------------------------------------ def _before_execute(self) -> None: - self._sync_files() + now = _time.monotonic() + if now - self._last_sync_time >= _SYNC_INTERVAL_SECONDS: + self._sync_files() + self._last_sync_time = now def _run_bash(self, cmd_string: str, *, timeout: int | None = None, stdin_data: str | None = None): diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index 40910bfeb22..68156a4bb58 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -5,12 +5,15 @@ import shlex import shutil import subprocess import tempfile +import time as _time from pathlib import Path from tools.environments.base import BaseEnvironment logger = logging.getLogger(__name__) +_SYNC_INTERVAL_SECONDS = 5.0 + def _ensure_ssh_available() -> None: """Fail fast with a clear error when the SSH client is unavailable.""" @@ -39,6 +42,15 @@ class SSHEnvironment(BaseEnvironment): def __init__(self, host: str, user: str, cwd: str = "~", timeout: int = 60, port: int = 22, key_path: str = "", **kwargs): + if kwargs.get("persistent") is not None: + import warnings + warnings.warn( + "The 'persistent' parameter is no longer supported. " + "SSH backend now uses the unified spawn-per-call model " + "with login-shell snapshot sourcing.", + DeprecationWarning, + stacklevel=2, + ) super().__init__(cwd=cwd, timeout=timeout) self.host = host self.user = user @@ -53,6 +65,7 @@ class SSHEnvironment(BaseEnvironment): self._synced_files: dict[str, tuple] = {} # remote_path → (mtime, size) self._skills_fingerprint: set | None = None # {(relpath, mtime, size), ...} self._created_remote_dirs: set[str] = set() + self._last_sync_time: float = 0.0 _ensure_ssh_available() self._establish_connection() @@ -213,9 +226,12 @@ class SSHEnvironment(BaseEnvironment): def _before_execute(self): """Incremental sync before each command so mid-session credential refreshes and skill updates are picked up.""" - self._sync_skills_and_credentials() + now = _time.monotonic() + if now - self._last_sync_time >= _SYNC_INTERVAL_SECONDS: + self._sync_skills_and_credentials() + self._last_sync_time = now - def _run_bash(self, cmd_string, *, stdin_data=None): + def _run_bash(self, cmd_string, *, timeout=None, stdin_data=None): cmd = self._build_ssh_command() cmd.extend(["bash", "-c", shlex.quote(cmd_string)]) proc = subprocess.Popen( @@ -232,7 +248,7 @@ class SSHEnvironment(BaseEnvironment): pass return proc - def _run_bash_login(self, cmd_string): + def _run_bash_login(self, cmd_string, *, timeout=None): cmd = self._build_ssh_command() cmd.extend(["bash", "-l", "-c", shlex.quote(cmd_string)]) return subprocess.Popen(