From 02cc9f03bbb962d186fa9a6a0c134248798fe33f Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Thu, 10 Sep 2026 00:36:33 -0500 Subject: [PATCH 1/2] fix(cli): load the agent's .env into locally run ACP and worker processes The docs say a .env next to manifest.yaml is loaded automatically for local development, but nothing did: agentex agents run built the child environment from os.environ plus the manifest, and EnvironmentVariables.refresh() looks for .env two directories above the installed module (site-packages), never the agent folder. Values in .env only reached a process when some import happened to call load_dotenv() first (litellm does, on import). That is why the OpenAI Agents Temporal template worked (its model client is built lazily after imports) while the Pydantic AI one failed at import time with openai.OpenAIError: Missing credentials. Merge /.env into the environment handed to both the ACP and worker subprocesses, without overriding variables already set in the shell (python-dotenv semantics). Add tests for merge, shell precedence, and the no-file / no-manifest_dir cases. Claude-Session: https://claude.ai/code/session_01HCVKnA7LeJZ44nxZz1uzF3 --- src/agentex/lib/cli/handlers/run_handlers.py | 16 ++++- tests/lib/cli/test_run_handlers_env.py | 67 ++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/lib/cli/test_run_handlers_env.py diff --git a/src/agentex/lib/cli/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py index 18ee84e93..0db26978d 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,22 @@ 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) + # Local development: load the .env next to manifest.yaml into BOTH the ACP and + # worker processes (the docs promise this). Variables already set in the shell + # win, matching python-dotenv's default. Without this, 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(): + for key, value in dotenv_values(env_file).items(): + if value is not None and key not in env: + env[key] = value + agent_config = manifest.agent # TODO: Combine this logic with the deploy_handlers so that we can reuse the env vars 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..0cecc65ee --- /dev/null +++ b/tests/lib/cli/test_run_handlers_env.py @@ -0,0 +1,67 @@ +"""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) From 0a248416cf351d035fd6bd892376971d11bcf56f Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Thu, 10 Sep 2026 12:19:31 -0500 Subject: [PATCH 2/2] fix(cli): apply .env after the built-in local defaults Loading .env before env_vars meant env.update(env_vars) clobbered any overlapping key, so a custom REDIS_URL or TEMPORAL_ADDRESS in .env had no effect. Apply .env after the defaults, below the manifest env block and the shell, and keep ENVIRONMENT pinned to development; add a regression test. Claude-Session: https://claude.ai/code/session_01HCVKnA7LeJZ44nxZz1uzF3 --- src/agentex/lib/cli/handlers/run_handlers.py | 27 ++++++++++++-------- tests/lib/cli/test_run_handlers_env.py | 14 ++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/agentex/lib/cli/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py index 0db26978d..cc9e032c2 100644 --- a/src/agentex/lib/cli/handlers/run_handlers.py +++ b/src/agentex/lib/cli/handlers/run_handlers.py @@ -413,16 +413,6 @@ def create_agent_environment(manifest: AgentManifest, manifest_dir: Path | None # Start with current environment env = dict(os.environ) - # Local development: load the .env next to manifest.yaml into BOTH the ACP and - # worker processes (the docs promise this). Variables already set in the shell - # win, matching python-dotenv's default. Without this, 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(): - for key, value in dotenv_values(env_file).items(): - if value is not None and key not in env: - env[key] = value agent_config = manifest.agent @@ -469,6 +459,23 @@ def create_agent_environment(manifest: AgentManifest, manifest_dir: Path | None 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 index 0cecc65ee..dc2e23b56 100644 --- a/tests/lib/cli/test_run_handlers_env.py +++ b/tests/lib/cli/test_run_handlers_env.py @@ -65,3 +65,17 @@ def test_missing_dotenv_and_no_manifest_dir_are_fine(project_dir: Path, monkeypa 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"