From 0fa93b6d254788a2213708873d46225d6f5132e6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:56:58 +0000 Subject: [PATCH 1/6] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index ee6f43e93..e45c32e4f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-7acaeb315af90255109ae17afc71e32a8e5851bb8a956a2a284cb4d344dfab51.yml -openapi_spec_hash: 3044e94b48d60311b6048e8df88e7552 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-7074b9156acbeefa63e9ca2173e9c22768268e894a48f511ec902fdcff043407.yml +openapi_spec_hash: 400e8dc4ce4d49db45e2943f67fe255a config_hash: 593e89b291976a5e84e4c3c3f8324354 From 76252a98f28663e8c95777456d07e42171592c62 Mon Sep 17 00:00:00 2001 From: Cynthia Wang Date: Mon, 31 Aug 2026 11:08:39 -0400 Subject: [PATCH 2/6] feat(tracing): add opt-in commit SHA stamping for SGP spans (#505) Co-authored-by: Claude Opus 5 --- src/agentex/lib/adk/__init__.py | 4 + src/agentex/lib/core/tracing/code_revision.py | 105 +++++++++++++++++ .../processors/sgp_tracing_processor.py | 28 ++++- src/agentex/lib/environment_variables.py | 7 ++ .../processors/test_sgp_tracing_processor.py | 60 ++++++++++ tests/lib/core/tracing/test_code_revision.py | 109 ++++++++++++++++++ 6 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 src/agentex/lib/core/tracing/code_revision.py create mode 100644 tests/lib/core/tracing/test_code_revision.py diff --git a/src/agentex/lib/adk/__init__.py b/src/agentex/lib/adk/__init__.py index d5be0ac52..c05f8f3ea 100644 --- a/src/agentex/lib/adk/__init__.py +++ b/src/agentex/lib/adk/__init__.py @@ -31,6 +31,9 @@ # Data-source refs for lineage (SGP-6513); implementation lives in core.tracing from agentex.lib.core.tracing import lineage + +# Opt-in commit-SHA stamping (AGX1-969); implementation in core.tracing +from agentex.lib.core.tracing import code_revision from agentex.lib.core.tracing.lineage import DataSourceRef, data_sources # Unified harness surface (AGX1-375) @@ -73,6 +76,7 @@ "TurnSpan", # Lineage data-source refs (SGP-6513) "lineage", + "code_revision", "DataSourceRef", "data_sources", # Checkpointing / LangGraph diff --git a/src/agentex/lib/core/tracing/code_revision.py b/src/agentex/lib/core/tracing/code_revision.py new file mode 100644 index 000000000..7b08dd45f --- /dev/null +++ b/src/agentex/lib/core/tracing/code_revision.py @@ -0,0 +1,105 @@ +"""Opt-in stamping of the agent's source commit onto its spans. + +Nothing is stamped until the agent calls :func:`enable`, mirroring the +``lineage`` registry next door: a process-wide switch the agent sets once at +import, rather than automatic behaviour every agent inherits. When enabled the +resolved commit lands in span data under ``__commit_sha__`` and is searchable in +the SGP Traces UI as ``__commit_sha__:``. + +This is deliberately separate from ``__agent_version__``, which is automatic and +carries the deployed image tag verbatim ("image tag or git sha"). That tag is a +real commit on some build paths but an ``-`` composite (AWS +ECR), ``latest``, or a hand-passed tag on others -- so a field named for a commit +must not simply mirror it. Values that are not git object names are refused, and +a field named ``__commit_sha__`` therefore only ever holds one. +""" + +from __future__ import annotations + +import os +import re + +from agentex.lib.utils.logging import make_logger + +__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha") + +logger = make_logger(__name__) + +COMMIT_SHA_KEY = "__commit_sha__" + +# A git object name: 40 hex for SHA-1, 64 for SHA-256, or an abbreviation down to +# git's own 7-character minimum. +_GIT_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}") + +_COMMIT_SHA_ENV = "AGENT_COMMIT_SHA" +# Fallback only: automatic, and only usable when it happens to be SHA-shaped. +_AGENT_VERSION_ENV = "AGENT_VERSION" + +# Resolved once at enable() rather than per span: the value is fixed for the +# life of the process, and resolving eagerly means a bad value is reported at +# startup instead of silently producing unstamped spans. +_commit_sha: str | None = None + + +def enable(commit_sha: str | None = None) -> None: + """Opt this process in to stamping ``__commit_sha__`` onto every span. + + Value precedence: the explicit ``commit_sha`` argument, else + ``AGENT_COMMIT_SHA``, else ``AGENT_VERSION`` when the deployment happened to + set it to a bare commit SHA. A value that is not a git object name is + refused with a warning and leaves stamping off -- better an absent field + than one named for a commit that holds an image tag. + """ + global _commit_sha + + for value, source in ( + (commit_sha, "the commit_sha argument"), + (os.environ.get(_COMMIT_SHA_ENV), _COMMIT_SHA_ENV), + (os.environ.get(_AGENT_VERSION_ENV), _AGENT_VERSION_ENV), + ): + candidate = (value or "").strip() + if not candidate: + continue + if _GIT_SHA_RE.fullmatch(candidate): + _commit_sha = candidate + logger.info("code revision stamping enabled from %s", source) + return + # An explicit argument or AGENT_COMMIT_SHA is a direct statement of + # intent, so a bad value there is worth surfacing. AGENT_VERSION is only + # a fallback and is expected to be a non-SHA tag much of the time, so + # falling through it quietly is correct, not a silent failure. + if source != _AGENT_VERSION_ENV: + logger.warning( + "%s=%r is not a git commit SHA; __commit_sha__ will not be stamped.", + source, + candidate, + ) + _commit_sha = None + return + + _commit_sha = None + logger.warning( + "code revision stamping was enabled but no commit SHA was found " + "(checked the commit_sha argument, %s, and %s); __commit_sha__ will not " + "be stamped. Set %s in the agent's environment -- e.g. bake it at build " + "time with a Dockerfile ARG/ENV.", + _COMMIT_SHA_ENV, + _AGENT_VERSION_ENV, + _COMMIT_SHA_ENV, + ) + + +def disable() -> None: + """Turn stamping back off (also used for test isolation).""" + global _commit_sha + _commit_sha = None + + +def is_enabled() -> bool: + """Whether a commit SHA resolved and will be stamped.""" + return _commit_sha is not None + + +def commit_sha() -> str | None: + """The resolved commit SHA, or ``None`` when stamping is not enabled.""" + return _commit_sha diff --git a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py index a1c0edca2..9ee269231 100644 --- a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py +++ b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py @@ -3,7 +3,7 @@ import os import asyncio import weakref -from typing import cast, override +from typing import Any, cast, override import scale_gp_beta.lib.tracing as tracing from scale_gp_beta import SGPClient, AsyncSGPClient @@ -11,6 +11,7 @@ from scale_gp_beta.lib.tracing.span import Span as SGPSpan from agentex.types.span import Span +from agentex.lib.core.tracing import code_revision from agentex.lib.types.tracing import SGPTracingProcessorConfig from agentex.lib.utils.logging import make_logger from agentex.lib.core.observability import tracing_metrics_recording as _metrics @@ -69,6 +70,29 @@ def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None: span.data["__agent_version__"] = env_vars.AGENT_VERSION +def _sgp_metadata(span: Span) -> Any: + """Metadata for the SGP write: ``span.data`` plus the opt-in commit SHA. + + Returns a COPY rather than mutating ``span``. ``trace.py`` hands the same + Span instance to every registered processor, so anything written onto + ``span.data`` here would also be serialized by the Agentex processor and + show up in caller-visible span data. ``__commit_sha__`` is opt-in and + SGP-scoped, so it must not leak that way. + + (The ``__source__`` / ``__agent_*`` keys set by ``_add_source_to_span`` do + leak like that today. Left as-is: changing five long-shipped fields is not + this change's business.) + """ + commit_sha = code_revision.commit_sha() + if commit_sha is None: + return span.data + if isinstance(span.data, dict): + return {**span.data, code_revision.COMMIT_SHA_KEY: commit_sha} + # List-shaped data is an accepted `data` shape and has nowhere to put a + # metadata key; leave it untouched rather than dropping the caller's data. + return span.data + + def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan: """Build an SGPSpan from an agentex Span. Idempotent on span_id at the SGP backend.""" _add_source_to_span(span, env_vars) @@ -82,7 +106,7 @@ def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan: trace_id=span.trace_id, input=span.input, output=span.output, - metadata=span.data, + metadata=_sgp_metadata(span), ), ) sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr] diff --git a/src/agentex/lib/environment_variables.py b/src/agentex/lib/environment_variables.py index 7d893e462..00dbbaada 100644 --- a/src/agentex/lib/environment_variables.py +++ b/src/agentex/lib/environment_variables.py @@ -25,6 +25,7 @@ class EnvVarKeys(str, Enum): AGENT_DESCRIPTION = "AGENT_DESCRIPTION" AGENT_ID = "AGENT_ID" AGENT_VERSION = "AGENT_VERSION" + AGENT_COMMIT_SHA = "AGENT_COMMIT_SHA" AGENT_API_KEY = "AGENT_API_KEY" # ACP Configuration ACP_URL = "ACP_URL" @@ -67,6 +68,12 @@ class EnvironmentVariables(BaseModel): AGENT_ID: str | None = None # Build/version discriminator (image tag or git sha), set by the deployment AGENT_VERSION: str | None = None + # The agent's source commit, baked into the image or set by the deployment. + # Unlike AGENT_VERSION this is expected to be a git SHA and nothing else, and + # it is OPT-IN: nothing is stamped unless the agent calls + # `adk.code_revision.enable()`, which also refuses a value that is not a git + # object name. See agentex.lib.core.tracing.code_revision. + AGENT_COMMIT_SHA: str | None = None AGENT_API_KEY: str | None = None ACP_TYPE: str | None = "async" AGENT_INPUT_TYPE: str | None = None diff --git a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py index 4a233fb72..6cd324f01 100644 --- a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py +++ b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py @@ -54,6 +54,66 @@ def test_agent_identity_and_version_stamped_into_span_data(self): "__agent_version__": "sha-abc123", } + SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + + def test_commit_sha_is_not_stamped_without_opt_in(self, monkeypatch): + """Upgrading the SDK must not start emitting __commit_sha__ on its own, + even when the environment carries a perfectly good SHA.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.disable() + + span = _make_span(); span.data = {} + assert "__commit_sha__" not in (_sgp_metadata(span) or {}) + + def test_commit_sha_is_stamped_after_opt_in(self, monkeypatch): + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = {"caller": "kept"} + metadata = _sgp_metadata(span) + assert metadata["__commit_sha__"] == self.SHA + assert metadata["caller"] == "kept" + finally: + code_revision.disable() + + def test_commit_sha_does_not_leak_onto_the_shared_span(self, monkeypatch): + """trace.py hands ONE Span to every processor. If the commit SHA were + written onto span.data, a co-registered Agentex processor would + serialize it too, and it would surface in caller-visible span data.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + from agentex.lib.core.tracing.processors.agentex_tracing_processor import _create_kwargs + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = {} + assert _sgp_metadata(span)["__commit_sha__"] == self.SHA # SGP sees it + assert "__commit_sha__" not in span.data # the span does not + assert "__commit_sha__" not in (_create_kwargs(span)["data"] or {}) + finally: + code_revision.disable() + + def test_list_shaped_data_is_left_alone(self, monkeypatch): + """`data` may be a list of dicts; there is nowhere to put a metadata key, + and dropping the caller's data would be worse than omitting the field.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = [{"a": 1}] + assert _sgp_metadata(span) == [{"a": 1}] + finally: + code_revision.disable() + def test_unset_identity_fields_are_omitted(self): from agentex.lib.core.tracing.processors.sgp_tracing_processor import _add_source_to_span diff --git a/tests/lib/core/tracing/test_code_revision.py b/tests/lib/core/tracing/test_code_revision.py new file mode 100644 index 000000000..0b89b88f2 --- /dev/null +++ b/tests/lib/core/tracing/test_code_revision.py @@ -0,0 +1,109 @@ +"""Opt-in commit-SHA stamping. + +The contract that matters: an agent that does not call ``enable()`` gets nothing, +so upgrading the SDK never starts emitting this field on its own. +""" + +from __future__ import annotations + +import pytest + +from agentex.lib.core.tracing import code_revision + +SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + + +@pytest.fixture(autouse=True) +def _reset(): + """State is process-wide (like the lineage registry), so isolate each test.""" + code_revision.disable() + yield + code_revision.disable() + + +class TestOptIn: + def test_disabled_by_default(self, monkeypatch): + """Even with the env fully populated, nothing resolves until enable().""" + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + monkeypatch.setenv("AGENT_VERSION", SHA) + assert code_revision.commit_sha() is None + assert code_revision.is_enabled() is False + + def test_enable_reads_agent_commit_sha(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable() + assert code_revision.commit_sha() == SHA + assert code_revision.is_enabled() is True + + def test_explicit_argument_wins(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable("7f3a91c2") + assert code_revision.commit_sha() == "7f3a91c2" + + def test_disable_turns_it_back_off(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable() + code_revision.disable() + assert code_revision.commit_sha() is None + + +class TestValueIsAlwaysACommit: + """A field named for a commit must never hold an image tag.""" + + @pytest.mark.parametrize( + "value", + [ + "latest", + "v1.2.3", + "0.2.4-v4", + "rocket_mock_agent-b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d", # AWS ECR composite + "abc", # shorter than git's 7-char minimum + "z" * 40, # right length, not hex + ], + ) + def test_non_sha_is_refused(self, monkeypatch, value): + monkeypatch.setenv("AGENT_COMMIT_SHA", value) + code_revision.enable() + assert code_revision.commit_sha() is None + + @pytest.mark.parametrize("value", [SHA, SHA.upper(), "b362b17", "a" * 64]) + def test_git_object_names_are_accepted(self, monkeypatch, value): + monkeypatch.setenv("AGENT_COMMIT_SHA", value) + code_revision.enable() + assert code_revision.commit_sha() == value + + def test_whitespace_only_is_refused(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", " ") + code_revision.enable() + assert code_revision.commit_sha() is None + + def test_enable_with_nothing_available_is_a_no_op(self, monkeypatch): + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.delenv("AGENT_VERSION", raising=False) + code_revision.enable() + assert code_revision.commit_sha() is None + + +class TestAgentVersionFallback: + def test_falls_back_to_agent_version_when_sha_shaped(self, monkeypatch): + """A platform deploy already sets AGENT_VERSION; on GCP/Azure it is a + bare SHA, so an opting-in agent needs no extra plumbing.""" + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.setenv("AGENT_VERSION", SHA) + code_revision.enable() + assert code_revision.commit_sha() == SHA + + def test_does_not_fall_back_to_a_non_sha_agent_version(self, monkeypatch): + """AGENT_VERSION is 'latest' or an AWS composite much of the time.""" + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.setenv("AGENT_VERSION", "latest") + code_revision.enable() + assert code_revision.commit_sha() is None + + def test_bad_explicit_value_does_not_fall_through(self, monkeypatch): + """An explicit AGENT_COMMIT_SHA is a statement of intent: if it is wrong, + say so rather than silently substituting the image tag.""" + monkeypatch.setenv("AGENT_COMMIT_SHA", "not-a-sha") + monkeypatch.setenv("AGENT_VERSION", SHA) + code_revision.enable() + assert code_revision.commit_sha() is None From f394ce7f1dfd0088eb63e7cdf64ff37307990706 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:15:08 +0000 Subject: [PATCH 3/6] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index e45c32e4f..955f7e2ac 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-7074b9156acbeefa63e9ca2173e9c22768268e894a48f511ec902fdcff043407.yml -openapi_spec_hash: 400e8dc4ce4d49db45e2943f67fe255a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-ee0c521f0612c31b874bd595b90cd9209545bab603983552a1a7a87f38ed931e.yml +openapi_spec_hash: 917a1ffe9e353bed2740524dec786ed2 config_hash: 593e89b291976a5e84e4c3c3f8324354 From 0db6037e63ca1b24b80ae0d38883f7687ae5b9e5 Mon Sep 17 00:00:00 2001 From: Rishav Chakravarti Date: Wed, 9 Sep 2026 10:13:03 -0400 Subject: [PATCH 4/6] fix: keep agent output streaming alive on an unreadable line, and honor LOG_LEVEL (#509) --- src/agentex/lib/cli/debug/debug_handlers.py | 3 + src/agentex/lib/cli/handlers/run_handlers.py | 60 ++++++- src/agentex/lib/cli/utils/cli_utils.py | 12 ++ src/agentex/lib/utils/logging.py | 21 ++- tests/lib/cli/test_run_handlers_streaming.py | 180 +++++++++++++++++++ tests/lib/utils/test_logging_level.py | 66 +++++++ 6 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 tests/lib/cli/test_run_handlers_streaming.py create mode 100644 tests/lib/utils/test_logging_level.py diff --git a/src/agentex/lib/cli/debug/debug_handlers.py b/src/agentex/lib/cli/debug/debug_handlers.py index 98746387f..a27d682cd 100644 --- a/src/agentex/lib/cli/debug/debug_handlers.py +++ b/src/agentex/lib/cli/debug/debug_handlers.py @@ -16,6 +16,7 @@ pass from agentex.lib.utils.logging import make_logger +from agentex.lib.cli.utils.cli_utils import SUBPROCESS_STREAM_LIMIT from .debug_config import DebugConfig, resolve_debug_port @@ -66,6 +67,7 @@ async def start_temporal_worker_debug( env=debug_env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, ) @@ -119,6 +121,7 @@ async def start_acp_server_debug( env=debug_env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, ) diff --git a/src/agentex/lib/cli/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py index 3a43e95dd..18ee84e93 100644 --- a/src/agentex/lib/cli/handlers/run_handlers.py +++ b/src/agentex/lib/cli/handlers/run_handlers.py @@ -12,6 +12,7 @@ from agentex.lib.cli.debug import DebugConfig, start_acp_server_debug, start_temporal_worker_debug from agentex.lib.utils.logging import make_logger from agentex.config.agent_manifest import AgentManifest +from agentex.lib.cli.utils.cli_utils import SUBPROCESS_STREAM_LIMIT from agentex.lib.cli.utils.path_utils import ( get_file_paths, calculate_uvicorn_target_for_local, @@ -23,6 +24,11 @@ logger = make_logger(__name__) console = Console() +# How many consecutive unreadable lines to skip before giving up on the stream. +# Skipping is only known-safe for the limit-overrun case; this bounds the damage +# if some other error repeats without consuming anything. +MAX_CONSECUTIVE_READ_ERRORS = 100 + class RunError(Exception): """An error occurred during agent run""" @@ -215,6 +221,7 @@ async def start_acp_server( env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, ) @@ -234,23 +241,68 @@ async def start_temporal_worker( env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, ) async def stream_process_output(process: asyncio.subprocess.Process, prefix: str): - """Stream process output with prefix""" + """Stream process output with prefix. + + This loop is the only reader of the child's stdout pipe. If it ever stops + reading, the pipe fills and the child blocks forever inside ``write()``, + which presents as a silent freeze: 0% CPU, no further logs, no traceback. + So a single unreadable line must never end the loop. + """ try: if process.stdout is None: return + consecutive_read_errors = 0 while True: - line = await process.stdout.readline() + try: + line = await process.stdout.readline() + except ValueError as e: + # readline() raises ValueError when a line exceeds the stream limit. + # In *that* case it has already discarded the line and resumed the + # transport, so skipping it makes guaranteed progress. Any other + # ValueError carries no such guarantee, and retrying it forever would + # spin without draining. We cannot tell the two apart (readline + # flattens LimitOverrunError into a bare ValueError), so bound the + # retries and let the outer handler report the hang risk. + consecutive_read_errors += 1 + if consecutive_read_errors > MAX_CONSECUTIVE_READ_ERRORS: + raise + logger.warning( + f"Skipping an unreadable line from {prefix}: {e!r} " + f"(consecutive failure {consecutive_read_errors}/{MAX_CONSECUTIVE_READ_ERRORS}). " + f"If this says the chunk exceeded the limit, raise limit= on this " + f"process's create_subprocess_exec." + ) + continue + + consecutive_read_errors = 0 + if not line: break - decoded_line = line.decode("utf-8").rstrip() + + try: + decoded_line = line.decode("utf-8").rstrip() + except UnicodeDecodeError as e: + logger.warning(f"Dropped an undecodable log line from {prefix} ({e}).") + continue + if decoded_line: # Only print non-empty lines console.print(f"[dim]{prefix}:[/dim] {decoded_line}") except Exception as e: - logger.debug(f"Output streaming ended for {prefix}: {e}") + # The escalation path, including for the re-raise above. Anything reaching + # here ends the loop, so the child is now at risk of blocking on a full pipe. + # Warning rather than debug: this used to be a debug() that make_logger could + # never emit, which is why three freezes produced no clue. + # CancelledError derives from BaseException, so the auto-reload path that + # cancels these tasks passes straight through and is unaffected. + logger.warning( + f"Output streaming for {prefix} stopped on {e!r}. " + f"Nothing is draining its stdout now, so {prefix} will hang once the pipe fills." + ) async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None): diff --git a/src/agentex/lib/cli/utils/cli_utils.py b/src/agentex/lib/cli/utils/cli_utils.py index 43b3fba62..4238e8fd9 100644 --- a/src/agentex/lib/cli/utils/cli_utils.py +++ b/src/agentex/lib/cli/utils/cli_utils.py @@ -5,6 +5,18 @@ console = Console() +# asyncio's StreamReader defaults to 64 KiB, and a single log line above that makes +# readline() raise. Agents legitimately emit large lines (serialized charts, payloads +# echoed back by validation errors), so give the reader room before it has to drop one. +# +# Lives here rather than beside its users so that both the normal spawns in +# cli/handlers/run_handlers.py and the debug spawns in cli/debug/debug_handlers.py can +# import it: run_handlers imports cli.debug, so the constant cannot live in either one. +# Keep the two in step. A subprocess left on the asyncio default overruns far more +# easily, and enough consecutive overruns exhaust the reader's retry bound and stop it +# draining, which is the deadlock the bound is there to avoid. +SUBPROCESS_STREAM_LIMIT = 8 * 1024 * 1024 + def handle_questionary_cancellation( result: str | None, operation: str = "operation" diff --git a/src/agentex/lib/utils/logging.py b/src/agentex/lib/utils/logging.py index 5bbaf61ac..a0d39331b 100644 --- a/src/agentex/lib/utils/logging.py +++ b/src/agentex/lib/utils/logging.py @@ -11,6 +11,25 @@ ctx_var_request_id = contextvars.ContextVar[str]("request_id") +DEFAULT_LOG_LEVEL = logging.INFO + + +def resolve_log_level() -> int: + """Read the log level from ``LOG_LEVEL``, falling back to INFO. + + Read straight from the environment rather than through ``EnvVarKeys``, since + ``environment_variables`` imports this module and the reverse would be a cycle. + + ``getLevelName`` returns the string ``"Level FOO"`` for anything it does not + recognise, so the isinstance check is what stops a typo in ``LOG_LEVEL`` from + silently turning logging off. + """ + configured = os.getenv("LOG_LEVEL") + if not configured: + return DEFAULT_LOG_LEVEL + level = logging.getLevelName(configured.strip().upper()) + return level if isinstance(level, int) else DEFAULT_LOG_LEVEL + class CustomJSONFormatter(json_log_formatter.JSONFormatter): def json_record(self, message: str, extra: dict, record: logging.LogRecord) -> dict: # type: ignore[override] @@ -51,7 +70,7 @@ def make_logger(name: str) -> logging.Logger: """ # Create a console object to print colored text logger = logging.getLogger(name) - logger.setLevel(logging.INFO) + logger.setLevel(resolve_log_level()) environment = os.getenv("ENVIRONMENT") if environment == "local": diff --git a/tests/lib/cli/test_run_handlers_streaming.py b/tests/lib/cli/test_run_handlers_streaming.py new file mode 100644 index 000000000..8f0ab13b5 --- /dev/null +++ b/tests/lib/cli/test_run_handlers_streaming.py @@ -0,0 +1,180 @@ +"""Tests for run_handlers output streaming. + +stream_process_output is the only reader of a child's stdout pipe. If it stops +reading, the pipe fills and the child blocks forever inside write(), which +presents as a silent freeze with no traceback. These tests pin the behaviour +that prevents that: a line the reader cannot handle is skipped, not fatal. +""" + +from __future__ import annotations + +import sys +import asyncio +from typing import Any + +import pytest + +from agentex.lib.cli.debug import DebugMode, DebugConfig +from agentex.lib.cli.handlers import run_handlers +from agentex.lib.cli.debug.debug_handlers import ( + start_acp_server_debug, + start_temporal_worker_debug, +) +from agentex.lib.cli.handlers.run_handlers import ( + SUBPROCESS_STREAM_LIMIT, + start_acp_server, + start_temporal_worker, + stream_process_output, +) + +# Emits a line of MARKER over the reader's limit, then enough further output to +# more than fill a 64 KiB pipe. If the reader stops draining, the child cannot +# finish its writes and never exits. +MARKER = "X" + +CHILD_SCRIPT = """ +print("before") +print("{marker}" * {oversized}) +for i in range(2000): + print("after", i, "y" * 60) +print("done") +""" + + +async def _drain(limit: int, oversized: int) -> int | None: + """Run the child under stream_process_output. None means it never exited.""" + process = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + CHILD_SCRIPT.format(marker=MARKER, oversized=oversized), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + limit=limit, + ) + streamer = asyncio.create_task(stream_process_output(process, "TEST")) + try: + await asyncio.wait_for(asyncio.gather(streamer, process.wait()), timeout=60) + except TimeoutError: + process.kill() + await process.wait() + return None + return process.returncode + + +async def test_oversized_line_is_skipped_without_stalling_the_child( + capsys: pytest.CaptureFixture[str], +) -> None: + """A line past the reader's limit is dropped, and streaming continues. + + Before this was handled per line, readline() raised, the loop exited, and the + child deadlocked on a full pipe. The child reaching exit is the assertion. + """ + limit = 64 * 1024 + oversized = limit + 16_000 + + returncode = await _drain(limit=limit, oversized=oversized) + out = capsys.readouterr().out + + assert returncode == 0, "child did not exit: the reader stopped draining its pipe" + # The offending line is gone, but everything after it still streamed. + assert out.count(MARKER) == 0 + assert "done" in out + + +async def test_large_line_within_the_limit_is_streamed_in_full( + capsys: pytest.CaptureFixture[str], +) -> None: + """A line over asyncio's 64 KiB default still reaches the console under our limit. + + Counts marker characters rather than matching the line, because rich wraps + long output across terminal-width lines. + """ + oversized = 82_000 + + returncode = await _drain(limit=SUBPROCESS_STREAM_LIMIT, oversized=oversized) + out = capsys.readouterr().out + + assert returncode == 0 + assert out.count(MARKER) == oversized, "the large line was dropped rather than streamed" + + +class _AlwaysFailingReader: + """A reader whose readline() raises without consuming anything. + + The dangerous shape: skipping it makes no progress, so an unbounded retry + would spin at 100% CPU while still not draining the pipe. + """ + + def __init__(self) -> None: + self.attempts = 0 + + async def readline(self) -> bytes: + self.attempts += 1 + raise ValueError("unreadable, and nothing was consumed") + + +class _FakeProcess: + def __init__(self, stdout: Any) -> None: + self.stdout = stdout + + +async def test_repeated_unreadable_lines_give_up_instead_of_spinning() -> None: + """A ValueError that consumes nothing must not loop forever.""" + reader = _AlwaysFailingReader() + + await asyncio.wait_for( + stream_process_output(_FakeProcess(reader), "TEST"), timeout=30 + ) + + assert reader.attempts == run_handlers.MAX_CONSECUTIVE_READ_ERRORS + 1 + + +async def test_cancellation_is_not_swallowed() -> None: + """The auto-reload path cancels these tasks, so cancel must propagate. + + CancelledError derives from BaseException, so the outer `except Exception` + does not catch it. This pins that, since swallowing it would hang restarts. + """ + + class _NeverReturns: + async def readline(self) -> bytes: + await asyncio.sleep(3600) + return b"" + + task = asyncio.create_task(stream_process_output(_FakeProcess(_NeverReturns()), "TEST")) + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_every_spawn_uses_the_larger_limit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """Every spawn must pass limit=, including the debug ones. + + A subprocess left on asyncio's default overruns far more easily, and enough + consecutive overruns exhaust MAX_CONSECUTIVE_READ_ERRORS and stop the reader + draining, which is the deadlock the bound exists to avoid. + """ + seen: list[int | None] = [] + + async def fake_exec(*_args: Any, **kwargs: Any) -> None: + seen.append(kwargs.get("limit")) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + monkeypatch.setattr(run_handlers, "calculate_uvicorn_target_for_local", lambda *_: "project.acp") + + await start_acp_server(tmp_path / "acp.py", 8000, {}, tmp_path) + await start_temporal_worker(tmp_path / "run_worker.py", {}, tmp_path) + + # BOTH, since each helper refuses unless its own mode is enabled. + debug_config = DebugConfig( + enabled=True, mode=DebugMode.BOTH, port=5678, wait_for_attach=False, auto_port=False + ) + await start_acp_server_debug(tmp_path / "acp.py", 8000, {}, debug_config) + await start_temporal_worker_debug(tmp_path / "run_worker.py", {}, debug_config) + + assert seen == [SUBPROCESS_STREAM_LIMIT] * 4, f"a spawn is missing limit=: {seen}" + assert SUBPROCESS_STREAM_LIMIT > 64 * 1024, "asyncio's default is what breaks readline()" diff --git a/tests/lib/utils/test_logging_level.py b/tests/lib/utils/test_logging_level.py new file mode 100644 index 000000000..16b171e33 --- /dev/null +++ b/tests/lib/utils/test_logging_level.py @@ -0,0 +1,66 @@ +"""Tests for log level resolution in agentex.lib.utils.logging. + +The level used to be pinned to INFO with no override, so a debug() call could +never be emitted on any configuration. That is not just a missing feature: it +made diagnostics that were already written into the SDK unreachable. +""" + +from __future__ import annotations + +import logging + +import pytest + +from agentex.lib.utils.logging import ( + DEFAULT_LOG_LEVEL, + make_logger, + resolve_log_level, +) + + +def test_defaults_to_info_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LOG_LEVEL", raising=False) + + assert resolve_log_level() == DEFAULT_LOG_LEVEL == logging.INFO + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ("DEBUG", logging.DEBUG), + ("debug", logging.DEBUG), + (" WaRnInG ", logging.WARNING), + ("ERROR", logging.ERROR), + ("CRITICAL", logging.CRITICAL), + ], +) +def test_reads_level_from_env( + monkeypatch: pytest.MonkeyPatch, configured: str, expected: int +) -> None: + monkeypatch.setenv("LOG_LEVEL", configured) + + assert resolve_log_level() == expected + + +@pytest.mark.parametrize("configured", ["", " ", "VERBOSE", "10x", "TRUE"]) +def test_falls_back_to_info_on_an_unusable_value( + monkeypatch: pytest.MonkeyPatch, configured: str +) -> None: + """A typo must not silently disable logging. + + logging.getLevelName returns the string "Level FOO" for anything it does not + recognise, which would otherwise be handed straight to setLevel. + """ + monkeypatch.setenv("LOG_LEVEL", configured) + + assert resolve_log_level() == logging.INFO + + +def test_make_logger_applies_the_configured_level(monkeypatch: pytest.MonkeyPatch) -> None: + """The regression that mattered: a debug() call must be able to emit.""" + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + + logger = make_logger("agentex.tests.level_from_env") + + assert logger.level == logging.DEBUG + assert logger.isEnabledFor(logging.DEBUG) From eac56e86c3b896fd996478a6d8916b17a8a809b3 Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Thu, 10 Sep 2026 00:01:42 -0500 Subject: [PATCH 5/6] feat(templates): scaffold a .gitignore that excludes .env and .venv agentex init writes an .env.example and tells users to create .env with their API key, and the Dockerfile path is protected by .dockerignore, but no template ships a .gitignore. A user who git-inits the scaffolded folder commits .env (and .venv) on the first add. Add a .gitignore to every template (secrets, Python artifacts, tool caches; .env.example stays committable) and a test asserting each scaffold renders it. Claude-Session: https://claude.ai/code/session_01HCVKnA7LeJZ44nxZz1uzF3 --- src/agentex/lib/cli/commands/init.py | 1 + .../default-claude-code/.gitignore.j2 | 20 +++++++++++++++++++ .../cli/templates/default-codex/.gitignore.j2 | 20 +++++++++++++++++++ .../templates/default-langgraph/.gitignore.j2 | 20 +++++++++++++++++++ .../default-openai-agents/.gitignore.j2 | 20 +++++++++++++++++++ .../default-pydantic-ai/.gitignore.j2 | 20 +++++++++++++++++++ .../lib/cli/templates/default/.gitignore.j2 | 20 +++++++++++++++++++ .../templates/sync-claude-code/.gitignore.j2 | 20 +++++++++++++++++++ .../cli/templates/sync-codex/.gitignore.j2 | 20 +++++++++++++++++++ .../templates/sync-langgraph/.gitignore.j2 | 20 +++++++++++++++++++ .../.gitignore.j2 | 20 +++++++++++++++++++ .../sync-openai-agents/.gitignore.j2 | 20 +++++++++++++++++++ .../templates/sync-pydantic-ai/.gitignore.j2 | 20 +++++++++++++++++++ .../lib/cli/templates/sync/.gitignore.j2 | 20 +++++++++++++++++++ .../temporal-claude-code/.gitignore.j2 | 20 +++++++++++++++++++ .../templates/temporal-codex/.gitignore.j2 | 20 +++++++++++++++++++ .../temporal-langgraph/.gitignore.j2 | 20 +++++++++++++++++++ .../temporal-openai-agents/.gitignore.j2 | 20 +++++++++++++++++++ .../temporal-pydantic-ai/.gitignore.j2 | 20 +++++++++++++++++++ .../lib/cli/templates/temporal/.gitignore.j2 | 20 +++++++++++++++++++ tests/lib/cli/test_init_templates.py | 11 ++++++++++ 21 files changed, 392 insertions(+) create mode 100644 src/agentex/lib/cli/templates/default-claude-code/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/default/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/.gitignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/.gitignore.j2 diff --git a/src/agentex/lib/cli/commands/init.py b/src/agentex/lib/cli/commands/init.py index 9849e9bbc..96b31dca2 100644 --- a/src/agentex/lib/cli/commands/init.py +++ b/src/agentex/lib/cli/commands/init.py @@ -99,6 +99,7 @@ def create_project_structure( # Create root files root_templates = { ".dockerignore.j2": ".dockerignore", + ".gitignore.j2": ".gitignore", ".env.example.j2": ".env.example", "manifest.yaml.j2": "manifest.yaml", "README.md.j2": "README.md", diff --git a/src/agentex/lib/cli/templates/default-claude-code/.gitignore.j2 b/src/agentex/lib/cli/templates/default-claude-code/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-codex/.gitignore.j2 b/src/agentex/lib/cli/templates/default-codex/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-langgraph/.gitignore.j2 b/src/agentex/lib/cli/templates/default-langgraph/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-openai-agents/.gitignore.j2 b/src/agentex/lib/cli/templates/default-openai-agents/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/.gitignore.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/default/.gitignore.j2 b/src/agentex/lib/cli/templates/default/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/default/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-claude-code/.gitignore.j2 b/src/agentex/lib/cli/templates/sync-claude-code/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-codex/.gitignore.j2 b/src/agentex/lib/cli/templates/sync-codex/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-langgraph/.gitignore.j2 b/src/agentex/lib/cli/templates/sync-langgraph/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.gitignore.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/.gitignore.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/.gitignore.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync/.gitignore.j2 b/src/agentex/lib/cli/templates/sync/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/.gitignore.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-codex/.gitignore.j2 b/src/agentex/lib/cli/templates/temporal-codex/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/.gitignore.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/.gitignore.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/.gitignore.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal/.gitignore.j2 b/src/agentex/lib/cli/templates/temporal/.gitignore.j2 new file mode 100644 index 000000000..661b1317e --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/.gitignore.j2 @@ -0,0 +1,20 @@ +# Secrets and local config +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ + +# Tooling +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.DS_Store diff --git a/tests/lib/cli/test_init_templates.py b/tests/lib/cli/test_init_templates.py index ec809cbbf..7f79e9e33 100644 --- a/tests/lib/cli/test_init_templates.py +++ b/tests/lib/cli/test_init_templates.py @@ -137,3 +137,14 @@ def test_requirements_include_langgraph_plugin_and_temporal(self, tmp_path: Path requirements = (project_dir / "requirements.txt").read_text() assert "temporalio[langgraph]>=1.27.0" in requirements assert "langchain-openai" in requirements + + +@pytest.mark.parametrize("template_type", list(TemplateType)) +def test_all_templates_ship_a_gitignore_that_excludes_env(tmp_path: Path, template_type: TemplateType): + """Every scaffold ships a .gitignore so .env (API keys) and .venv are never committed.""" + project_dir = _render_project(tmp_path, template_type) + gitignore = project_dir / ".gitignore" + assert gitignore.is_file(), f"{template_type.value} did not render .gitignore" + lines = gitignore.read_text().splitlines() + assert ".env" in lines and ".venv/" in lines + assert "!.env.example" in lines, "the example env file should stay committable" From 837f57fcbe5e49367fdbd934ccafabb53f8daab8 Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Thu, 10 Sep 2026 12:18:19 -0500 Subject: [PATCH 6/6] fix(init): merge scaffold entries into an existing .gitignore instead of overwriting it Re-running agentex init on an existing project replaced the user's .gitignore. Keep the existing file and append only the scaffold entries that are missing, with a test for the merge. Claude-Session: https://claude.ai/code/session_01HCVKnA7LeJZ44nxZz1uzF3 --- src/agentex/lib/cli/commands/init.py | 12 +++++++++++- tests/lib/cli/test_init_templates.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/agentex/lib/cli/commands/init.py b/src/agentex/lib/cli/commands/init.py index 96b31dca2..a0ca5020b 100644 --- a/src/agentex/lib/cli/commands/init.py +++ b/src/agentex/lib/cli/commands/init.py @@ -119,7 +119,17 @@ def create_project_structure( for template, output in root_templates.items(): output_path = project_dir / output - output_path.write_text(render_template(template, context, template_type)) + rendered = render_template(template, context, template_type) + if output == ".gitignore" and output_path.exists(): + # Re-running init on an existing project: keep the user's rules and + # append only the scaffold entries that are missing. + existing = output_path.read_text() + existing_lines = {line.strip() for line in existing.splitlines()} + missing = [line for line in rendered.splitlines() if line.strip() and not line.startswith("#") and line.strip() not in existing_lines] + if missing: + output_path.write_text(existing.rstrip("\n") + "\n\n# Added by agentex init\n" + "\n".join(missing) + "\n") + continue + output_path.write_text(rendered) console.print(f"\n[green]✓[/green] Created project structure at: {project_dir}") diff --git a/tests/lib/cli/test_init_templates.py b/tests/lib/cli/test_init_templates.py index 7f79e9e33..c61571f5a 100644 --- a/tests/lib/cli/test_init_templates.py +++ b/tests/lib/cli/test_init_templates.py @@ -148,3 +148,18 @@ def test_all_templates_ship_a_gitignore_that_excludes_env(tmp_path: Path, templa lines = gitignore.read_text().splitlines() assert ".env" in lines and ".venv/" in lines assert "!.env.example" in lines, "the example env file should stay committable" + + +def test_rerunning_init_preserves_existing_gitignore_rules(tmp_path: Path): + """A pre-existing .gitignore keeps its own rules; missing scaffold entries are appended.""" + context = _context(TemplateType.SYNC) + project_dir = tmp_path / context["project_name"] + project_dir.mkdir(parents=True) + (project_dir / ".gitignore").write_text("# mine\n*.log\n.env\n") + + create_project_structure(tmp_path, context, TemplateType.SYNC, use_uv=True) + + lines = (project_dir / ".gitignore").read_text().splitlines() + assert "*.log" in lines, "user rule was dropped" + assert lines.count(".env") == 1, "existing entry duplicated" + assert ".venv/" in lines and "!.env.example" in lines, "scaffold entries not appended"