diff --git a/src/agentex/lib/cli/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py index 18ee84e93..cc9e032c2 100644 --- a/src/agentex/lib/cli/handlers/run_handlers.py +++ b/src/agentex/lib/cli/handlers/run_handlers.py @@ -5,6 +5,7 @@ import asyncio from pathlib import Path +from dotenv import dotenv_values from rich.panel import Panel from rich.console import Console @@ -332,7 +333,7 @@ async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = Non raise RunError("Temporal agent requires a worker file path to be configured") # Create environment for subprocesses - agent_env = create_agent_environment(manifest) + agent_env = create_agent_environment(manifest, manifest_dir=manifest_file.parent) # Setup process manager process_manager = ProcessManager() @@ -407,11 +408,12 @@ async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = Non -def create_agent_environment(manifest: AgentManifest) -> dict[str, str]: +def create_agent_environment(manifest: AgentManifest, manifest_dir: Path | None = None) -> dict[str, str]: """Create environment variables for agent processes without modifying os.environ""" # Start with current environment env = dict(os.environ) + agent_config = manifest.agent # TODO: Combine this logic with the deploy_handlers so that we can reuse the env vars @@ -457,6 +459,23 @@ def create_agent_environment(manifest: AgentManifest) -> dict[str, str]: env.update(env_vars) + # Local development: load the .env next to manifest.yaml into BOTH the ACP and + # worker processes (the docs promise this). Precedence, highest first: the + # manifest's env block, variables already set in the shell, then .env, then + # the built-in local defaults above (so .env can point at a custom Redis or + # Temporal). ENVIRONMENT stays "development": that is what makes this a + # local run. Without this block a value in .env only reaches a process if + # some import happens to call load_dotenv() first. + if manifest_dir is not None: + env_file = Path(manifest_dir) / ".env" + if env_file.is_file(): + manifest_env = agent_config.env or {} + for key, value in dotenv_values(env_file).items(): + if value is None or key == "ENVIRONMENT": + continue + if key in os.environ or key in manifest_env: + continue + env[key] = value return env diff --git a/tests/lib/cli/test_run_handlers_env.py b/tests/lib/cli/test_run_handlers_env.py new file mode 100644 index 000000000..dc2e23b56 --- /dev/null +++ b/tests/lib/cli/test_run_handlers_env.py @@ -0,0 +1,81 @@ +"""Tests for the environment `agentex agents run` hands to the ACP and worker processes.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agentex.lib.cli.commands.init import ( + TemplateType, + get_project_context, + create_project_structure, +) +from agentex.lib.cli.handlers.run_handlers import create_agent_environment +from agentex.lib.sdk.config.agent_manifest import load_agent_manifest + + +@pytest.fixture +def project_dir(tmp_path: Path) -> Path: + """A scaffolded Sync ACP project, so the manifest is a real one.""" + answers = { + "template_type": TemplateType.SYNC, + "project_path": str(tmp_path), + "agent_name": "env-agent", + "agent_directory_name": "env-agent", + "description": "An Agentex agent", + "use_uv": True, + } + context = get_project_context(answers, tmp_path, Path("../../")) + context["template_type"] = TemplateType.SYNC.value + context["use_uv"] = True + create_project_structure(tmp_path, context, TemplateType.SYNC, use_uv=True) + return tmp_path / context["project_name"] + + +def test_dotenv_next_to_manifest_is_loaded(project_dir: Path, monkeypatch: pytest.MonkeyPatch): + """Values in /.env reach the subprocess environment.""" + (project_dir / ".env").write_text("FROM_DOTENV=1\nLITELLM_API_KEY=sk-test\n") + monkeypatch.delenv("FROM_DOTENV", raising=False) + monkeypatch.delenv("LITELLM_API_KEY", raising=False) + manifest = load_agent_manifest(file_path=str(project_dir / "manifest.yaml")) + + env = create_agent_environment(manifest, manifest_dir=project_dir) + + assert env["FROM_DOTENV"] == "1" + assert env["LITELLM_API_KEY"] == "sk-test" + assert env["ENVIRONMENT"] == "development" + + +def test_shell_variables_win_over_dotenv(project_dir: Path, monkeypatch: pytest.MonkeyPatch): + """A variable already exported in the shell is not overridden by .env.""" + (project_dir / ".env").write_text("PRESET=from-dotenv\n") + monkeypatch.setenv("PRESET", "from-shell") + manifest = load_agent_manifest(file_path=str(project_dir / "manifest.yaml")) + + env = create_agent_environment(manifest, manifest_dir=project_dir) + + assert env["PRESET"] == "from-shell" + + +def test_missing_dotenv_and_no_manifest_dir_are_fine(project_dir: Path, monkeypatch: pytest.MonkeyPatch): + """No .env file, or no manifest_dir given, still builds a normal environment.""" + monkeypatch.delenv("FROM_DOTENV", raising=False) + manifest = load_agent_manifest(file_path=str(project_dir / "manifest.yaml")) + + assert "FROM_DOTENV" not in create_agent_environment(manifest, manifest_dir=project_dir) + assert "FROM_DOTENV" not in create_agent_environment(manifest) + + +def test_dotenv_overrides_builtin_local_defaults_but_not_environment(project_dir: Path, monkeypatch: pytest.MonkeyPatch): + """.env may point local runs at a custom Redis/Temporal; ENVIRONMENT stays development.""" + (project_dir / ".env").write_text("REDIS_URL=redis://custom:6380\nTEMPORAL_ADDRESS=temporal.internal:7233\nENVIRONMENT=production\n") + for key in ("REDIS_URL", "TEMPORAL_ADDRESS", "ENVIRONMENT"): + monkeypatch.delenv(key, raising=False) + manifest = load_agent_manifest(file_path=str(project_dir / "manifest.yaml")) + + env = create_agent_environment(manifest, manifest_dir=project_dir) + + assert env["REDIS_URL"] == "redis://custom:6380" + assert env["TEMPORAL_ADDRESS"] == "temporal.internal:7233" + assert env["ENVIRONMENT"] == "development"