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/9] 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/9] 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/9] 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 cab4bd6e33871a507440ad477db2ee130e834303 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:39 +0000 Subject: [PATCH 4/9] chore: release main --- .release-please-manifest.json | 4 ++-- CHANGELOG.md | 8 ++++++++ adk/CHANGELOG.md | 8 ++++++++ adk/pyproject.toml | 2 +- pyproject.toml | 2 +- src/agentex/_version.py | 2 +- 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f0e5160bd..95c44cfb4 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "0.25.0", - "adk": "0.25.0" + ".": "0.26.0", + "adk": "0.26.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 54338d9ba..a09e7cf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ * **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``. +## 0.26.0 (2026-09-01) + +Full Changelog: [agentex-client-v0.25.0...agentex-client-v0.26.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.25.0...agentex-client-v0.26.0) + +### Features + +* **tracing:** add opt-in commit SHA stamping for SGP spans ([#505](https://github.com/scaleapi/scale-agentex-python/issues/505)) ([76252a9](https://github.com/scaleapi/scale-agentex-python/commit/76252a98f28663e8c95777456d07e42171592c62)) + ## 0.25.0 (2026-08-26) Full Changelog: [agentex-client-v0.24.0...agentex-client-v0.25.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.24.0...agentex-client-v0.25.0) diff --git a/adk/CHANGELOG.md b/adk/CHANGELOG.md index 7619b4721..a07f93249 100644 --- a/adk/CHANGELOG.md +++ b/adk/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.26.0 (2026-09-01) + +Full Changelog: [agentex-sdk-v0.25.0...agentex-sdk-v0.26.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.25.0...agentex-sdk-v0.26.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + ## 0.25.0 (2026-08-26) Full Changelog: [agentex-sdk-v0.24.0...agentex-sdk-v0.25.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.24.0...agentex-sdk-v0.25.0) diff --git a/adk/pyproject.toml b/adk/pyproject.toml index b0b22ea51..b42b50e11 100644 --- a/adk/pyproject.toml +++ b/adk/pyproject.toml @@ -4,7 +4,7 @@ # (agentex/{__init__.py, _*.py, types/, resources/}) ships from the slim # sibling package `agentex-client` which is pinned as a runtime dep. name = "agentex-sdk" -version = "0.25.0" +version = "0.26.0" description = "Agent Development Kit (ADK) overlay for the Agentex API — FastACP server, Temporal workflows, LLM provider integrations, observability" license = "Apache-2.0" authors = [ diff --git a/pyproject.toml b/pyproject.toml index 1e09c8480..8fb1f97cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ # overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships # as the sibling `agentex-sdk` package — see `adk/pyproject.toml`. name = "agentex-client" -version = "0.25.0" +version = "0.26.0" description = "The official Python REST client for the Agentex API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/agentex/_version.py b/src/agentex/_version.py index 88d99ae5c..34aa48bd8 100644 --- a/src/agentex/_version.py +++ b/src/agentex/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "agentex" -__version__ = "0.25.0" # x-release-please-version +__version__ = "0.26.0" # x-release-please-version From 0db6037e63ca1b24b80ae0d38883f7687ae5b9e5 Mon Sep 17 00:00:00 2001 From: Rishav Chakravarti Date: Wed, 9 Sep 2026 10:13:03 -0400 Subject: [PATCH 5/9] 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 ebb632a4b22287e157bcf086dd98b27f26188fc5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:59:38 +0000 Subject: [PATCH 6/9] feat(api): add agent_card_metadata parameter to agents list method --- .stats.yml | 4 ++-- src/agentex/resources/agents/agents.py | 18 ++++++++++++++++++ src/agentex/types/agent_list_params.py | 10 ++++++++++ tests/api_resources/test_agents.py | 2 ++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 955f7e2ac..a15b6b97b 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-ee0c521f0612c31b874bd595b90cd9209545bab603983552a1a7a87f38ed931e.yml -openapi_spec_hash: 917a1ffe9e353bed2740524dec786ed2 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-fbc0683871d6abb03588f30d9468eeeeacb2b8538eb0c9002813e6df68c5802b.yml +openapi_spec_hash: 4ecd8d496f056dccf80826264ddf8fe1 config_hash: 593e89b291976a5e84e4c3c3f8324354 diff --git a/src/agentex/resources/agents/agents.py b/src/agentex/resources/agents/agents.py index 924fab3fb..7500648bd 100644 --- a/src/agentex/resources/agents/agents.py +++ b/src/agentex/resources/agents/agents.py @@ -116,6 +116,7 @@ def retrieve( def list( self, *, + agent_card_metadata: Optional[str] | Omit = omit, limit: int | Omit = omit, order_by: Optional[str] | Omit = omit, order_direction: str | Omit = omit, @@ -132,6 +133,13 @@ def list( List all registered agents, optionally filtered by query parameters. Args: + agent_card_metadata: JSON-encoded object used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. Only matches cards published through the direct + registration path: registrations that carry a `deployment_id` write the card to + the deployment record instead of `registration_metadata`, so those agents never + match this filter. + limit: Limit order_by: Field to order by @@ -159,6 +167,7 @@ def list( timeout=timeout, query=maybe_transform( { + "agent_card_metadata": agent_card_metadata, "limit": limit, "order_by": order_by, "order_direction": order_direction, @@ -777,6 +786,7 @@ async def retrieve( async def list( self, *, + agent_card_metadata: Optional[str] | Omit = omit, limit: int | Omit = omit, order_by: Optional[str] | Omit = omit, order_direction: str | Omit = omit, @@ -793,6 +803,13 @@ async def list( List all registered agents, optionally filtered by query parameters. Args: + agent_card_metadata: JSON-encoded object used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. Only matches cards published through the direct + registration path: registrations that carry a `deployment_id` write the card to + the deployment record instead of `registration_metadata`, so those agents never + match this filter. + limit: Limit order_by: Field to order by @@ -820,6 +837,7 @@ async def list( timeout=timeout, query=await async_maybe_transform( { + "agent_card_metadata": agent_card_metadata, "limit": limit, "order_by": order_by, "order_direction": order_direction, diff --git a/src/agentex/types/agent_list_params.py b/src/agentex/types/agent_list_params.py index 084fdfaed..f0a327aec 100644 --- a/src/agentex/types/agent_list_params.py +++ b/src/agentex/types/agent_list_params.py @@ -9,6 +9,16 @@ class AgentListParams(TypedDict, total=False): + agent_card_metadata: Optional[str] + """ + JSON-encoded object used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. Only matches cards published through the direct + registration path: registrations that carry a `deployment_id` write the card to + the deployment record instead of `registration_metadata`, so those agents never + match this filter. + """ + limit: int """Limit""" diff --git a/tests/api_resources/test_agents.py b/tests/api_resources/test_agents.py index 501dcb662..4fb6ea4e2 100644 --- a/tests/api_resources/test_agents.py +++ b/tests/api_resources/test_agents.py @@ -75,6 +75,7 @@ def test_method_list(self, client: Agentex) -> None: @parametrize def test_method_list_with_all_params(self, client: Agentex) -> None: agent = client.agents.list( + agent_card_metadata="agent_card_metadata", limit=1, order_by="order_by", order_direction="order_direction", @@ -469,6 +470,7 @@ async def test_method_list(self, async_client: AsyncAgentex) -> None: @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: agent = await async_client.agents.list( + agent_card_metadata="agent_card_metadata", limit=1, order_by="order_by", order_direction="order_direction", From 55a0178c2d210325770b56dbb58eac2f644cc14b Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Mon, 14 Sep 2026 14:01:50 -0400 Subject: [PATCH 7/9] feat(agent-card): add metadata field and expose list filter (#502) Co-authored-by: Claude Fable 5 --- .../lib/core/temporal/workers/worker.py | 4 +- src/agentex/lib/types/agent_card.py | 11 +- src/agentex/lib/utils/metadata_filters.py | 58 +++++++ .../workers/test_worker_version_guard.py | 2 +- tests/lib/test_agent_card.py | 58 +++++++ tests/lib/test_agentex_worker.py | 161 ++++++++++++++++++ tests/lib/test_metadata_filters.py | 112 ++++++++++++ 7 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 src/agentex/lib/utils/metadata_filters.py create mode 100644 tests/lib/test_metadata_filters.py diff --git a/src/agentex/lib/core/temporal/workers/worker.py b/src/agentex/lib/core/temporal/workers/worker.py index 9f0aa2da3..72631917f 100644 --- a/src/agentex/lib/core/temporal/workers/worker.py +++ b/src/agentex/lib/core/temporal/workers/worker.py @@ -178,6 +178,7 @@ def __init__( metrics_headers: dict[str, str] | None = None, metrics_use_http: bool = False, metrics_temporality_delta: bool = False, + agent_card: Any | None = None, ): self.task_queue = task_queue self.activity_handles = [] @@ -196,6 +197,7 @@ def __init__( self.metrics_temporality_delta = metrics_temporality_delta self.payload_codec = payload_codec self.data_converter = data_converter + self.agent_card = agent_card @overload async def run( @@ -312,6 +314,6 @@ async def _register_agent(self): # the worker process never goes through the ACP server lifespan, so it needs its # own guard (mirrors base_acp_server.lifespan_context). await assert_backend_compatible(env_vars.AGENTEX_BASE_URL) - await register_agent(env_vars) + await register_agent(env_vars, agent_card=self.agent_card) else: logger.warning("AGENTEX_BASE_URL not set, skipping worker registration") diff --git a/src/agentex/lib/types/agent_card.py b/src/agentex/lib/types/agent_card.py index def4464c6..d0af817a5 100644 --- a/src/agentex/lib/types/agent_card.py +++ b/src/agentex/lib/types/agent_card.py @@ -5,7 +5,7 @@ from enum import Enum from typing import TYPE_CHECKING, Any, get_args, get_origin -from pydantic import BaseModel +from pydantic import Field, BaseModel if TYPE_CHECKING: from agentex.lib.sdk.state_machine.state import State @@ -31,6 +31,11 @@ class AgentCard(BaseModel): data_events: list[str] = [] input_types: list[str] = [] output_schema: dict | None = None + # Free-form JSON object for opt-in self-description (e.g. protocol-specific + # capability flags). Not interpreted by the platform, but callers can filter + # agents on it with ``agents.list(agent_card_metadata=...)`` -- see + # ``agentex.lib.utils.metadata_filters.encode_metadata_filter``. + metadata: dict[str, Any] = Field(default_factory=dict) @classmethod def from_states( @@ -40,6 +45,7 @@ def from_states( output_event_model: type[BaseModel] | None = None, extra_input_types: list[str] | None = None, queries: list[str] | None = None, + metadata: dict[str, Any] | None = None, ) -> AgentCard: """Build an AgentCard directly from a list[State] + initial_state. @@ -81,6 +87,7 @@ def from_states( data_events=data_events, input_types=sorted(derived_input_types | set(extra_input_types or [])), output_schema=output_schema, + metadata=metadata or {}, ) @classmethod @@ -90,6 +97,7 @@ def from_state_machine( output_event_model: type[BaseModel] | None = None, extra_input_types: list[str] | None = None, queries: list[str] | None = None, + metadata: dict[str, Any] | None = None, ) -> AgentCard: """Build an AgentCard from a StateMachine instance. Delegates to from_states().""" lifecycle = state_machine.get_lifecycle() @@ -125,6 +133,7 @@ def from_state_machine( data_events=data_events, input_types=sorted(derived_input_types | set(extra_input_types or [])), output_schema=output_schema, + metadata=metadata or {}, ) diff --git a/src/agentex/lib/utils/metadata_filters.py b/src/agentex/lib/utils/metadata_filters.py new file mode 100644 index 000000000..22d8aeb59 --- /dev/null +++ b/src/agentex/lib/utils/metadata_filters.py @@ -0,0 +1,58 @@ +"""Helpers for the platform's JSON-encoded metadata filter query parameters. + +The containment filters on ``agents.list(agent_card_metadata=...)`` and +``tasks.list(task_metadata=...)`` carry their filter as a JSON-encoded object +inside a single query string value, so the generated clients type them as +``str``. Encoding by hand is easy to get subtly wrong -- Python's ``json`` +happily emits ``NaN``/``Infinity``, which the server rejects with a 400 -- so +these helpers do it once, here, in the hand-written layer where they survive +SDK regeneration. + + from agentex.lib.utils.metadata_filters import encode_metadata_filter + + client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True}), + ) + +The ``agent_card_metadata`` filter requires an Agentex server that includes +scaleapi/scale-agentex#411. Older servers ignore the unknown query parameter +and return the full unfiltered agent list rather than erroring, and the SDK's +startup backend-contract check does not guard against this. +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + +__all__ = ["encode_metadata_filter"] + + +def encode_metadata_filter(metadata: Mapping[str, Any]) -> str: + """Encode a metadata filter mapping into the wire form the platform expects. + + Args: + metadata: The key/value pairs the target's metadata object must contain. + Values may be any JSON type; matching is exact containment, so + ``{"permits_capable": True}`` matches a stored JSON ``true`` but not + the string ``"true"``. An empty mapping matches any target that has + a metadata object at all. + + Returns: + A compact JSON object string, with keys sorted so the same filter always + produces the same query value. + + Raises: + TypeError: If ``metadata`` is not a mapping, or contains a value that + isn't JSON-serializable. + ValueError: If a value is a non-finite float. ``NaN`` and ``Infinity`` + aren't valid JSON and the server rejects them with a 400, so fail + here with a clearer message instead. + """ + if not isinstance(metadata, Mapping): + raise TypeError(f"metadata must be a mapping, got {type(metadata).__name__}") + + try: + return json.dumps(metadata, allow_nan=False, separators=(",", ":"), sort_keys=True) + except ValueError as exc: + raise ValueError(f"metadata filter is not encodable as JSON: {exc}") from exc diff --git a/tests/lib/core/temporal/workers/test_worker_version_guard.py b/tests/lib/core/temporal/workers/test_worker_version_guard.py index 4ab5fc435..5c2c9fb47 100644 --- a/tests/lib/core/temporal/workers/test_worker_version_guard.py +++ b/tests/lib/core/temporal/workers/test_worker_version_guard.py @@ -40,7 +40,7 @@ async def test_guard_runs_before_register_agent(monkeypatch): await _worker()._register_agent() guard.assert_awaited_once_with("http://backend") - register.assert_awaited_once_with(env) + register.assert_awaited_once_with(env, agent_card=None) assert order == ["guard", "register"] # guard must precede registration diff --git a/tests/lib/test_agent_card.py b/tests/lib/test_agent_card.py index 5d57f9e8e..f9a99ffc5 100644 --- a/tests/lib/test_agent_card.py +++ b/tests/lib/test_agent_card.py @@ -189,6 +189,7 @@ def test_defaults(self): assert card.data_events == [] assert card.input_types == [] assert card.output_schema is None + assert card.metadata == {} def test_serialization_roundtrip(self): card = AgentCard(input_types=["text"], data_events=["result"]) @@ -196,6 +197,34 @@ def test_serialization_roundtrip(self): restored = AgentCard.model_validate(dumped) assert restored == card + def test_metadata_accepts_arbitrary_json_object(self): + card = AgentCard( + metadata={ + "permits_capable": True, + "supported_workflows": ["submit", "review"], + "limits": {"max_batch": 5}, + } + ) + assert card.metadata == { + "permits_capable": True, + "supported_workflows": ["submit", "review"], + "limits": {"max_batch": 5}, + } + + def test_metadata_serialization_roundtrip(self): + card = AgentCard(metadata={"permits_capable": True}) + dumped = card.model_dump() + assert dumped["metadata"] == {"permits_capable": True} + restored = AgentCard.model_validate(dumped) + assert restored == card + + def test_metadata_default_instances_are_independent(self): + """Each default metadata is its own dict, not a shared class-level object.""" + card_a = AgentCard() + card_b = AgentCard() + card_a.metadata["mutated"] = True + assert card_b.metadata == {} + # --- AgentCard.from_states --- @@ -247,6 +276,14 @@ def test_state_fields(self, sample_states): assert waiting.accepts == ["text", "doc_upload"] assert waiting.transitions == ["processing"] + def test_metadata_forwarded(self, sample_states): + card = AgentCard.from_states( + initial_state=SampleState.WAITING, + states=sample_states, + metadata={"permits_capable": True}, + ) + assert card.metadata == {"permits_capable": True} + def test_matches_from_state_machine(self, sample_states, sample_sm): """from_states and from_state_machine should produce identical cards.""" card_states = AgentCard.from_states( @@ -315,6 +352,13 @@ def test_no_output_model(self, sample_sm): assert card.data_events == [] assert card.output_schema is None + def test_metadata_forwarded(self, sample_sm): + card = AgentCard.from_state_machine( + state_machine=sample_sm, + metadata={"permits_capable": True}, + ) + assert card.metadata == {"permits_capable": True} + # --- register_agent agent_card merging --- @@ -370,6 +414,20 @@ async def test_agent_card_merged_into_metadata(self, mock_env_vars): assert metadata["agent_card"]["input_types"] == ["text"] assert metadata["agent_card"]["data_events"] == ["result"] + async def test_agent_card_metadata_propagates_through_registration(self, mock_env_vars): + card = AgentCard(metadata={"permits_capable": True}) + mock_client = self._make_mock_client() + + with patch("agentex.lib.utils.registration.httpx.AsyncClient", return_value=mock_client): + from agentex.lib.utils.registration import register_agent + + await register_agent(mock_env_vars, agent_card=card) + + sent_data = mock_client.post.call_args.kwargs["json"] + metadata = sent_data["registration_metadata"] + + assert metadata["agent_card"]["metadata"] == {"permits_capable": True} + async def test_none_preserved_when_no_card(self, mock_env_vars): mock_client = self._make_mock_client() diff --git a/tests/lib/test_agentex_worker.py b/tests/lib/test_agentex_worker.py index 370bd5e60..742ac3e74 100644 --- a/tests/lib/test_agentex_worker.py +++ b/tests/lib/test_agentex_worker.py @@ -117,6 +117,167 @@ def test_worker_metrics_params_default_to_none_and_false(self): assert worker.metrics_temporality_delta is False +class TestAgentexWorkerAgentCard: + """Tests that AgentexWorker publishes an optional AgentCard through the + existing automatic registration lifecycle.""" + + @pytest.fixture(autouse=True) + def cleanup_env(self): + yield + for key in ("AGENT_ID", "AGENT_NAME", "AGENT_API_KEY"): + os.environ.pop(key, None) + + @staticmethod + def _env_vars_mock(): + env = MagicMock() + env.AGENTEX_BASE_URL = "http://agentex.test" + env.ACP_URL = "http://agent.test" + env.ACP_PORT = 8000 + env.AGENT_DESCRIPTION = "test description" + env.AGENT_NAME = "test-agent" + env.ACP_TYPE = "agentic" + env.AUTH_PRINCIPAL_B64 = None + env.AGENTEX_DEPLOYMENT_ID = None + env.AGENT_ID = None + env.AGENT_INPUT_TYPE = None + return env + + @staticmethod + def _httpx_client_mock(captured_payloads): + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "id": "agent-id", + "name": "test-agent", + "agent_api_key": "api-key", + } + + async def post(url, json=None, timeout=None): # noqa: ARG001 + captured_payloads.append(json) + return response + + client = MagicMock() + client.__aenter__ = AsyncMock(return_value=MagicMock(post=AsyncMock(side_effect=post))) + client.__aexit__ = AsyncMock(return_value=False) + return MagicMock(return_value=client) + + def test_worker_agent_card_defaults_to_none(self): + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker(task_queue="test-queue", health_check_port=8080) + + assert worker.agent_card is None + + async def test_default_registration_calls_register_agent_without_card(self): + """The default worker still registers automatically and passes no card, + preserving existing callers and wire behavior.""" + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker(task_queue="test-queue", health_check_port=8080) + + with patch( + "agentex.lib.core.temporal.workers.worker.register_agent", new=AsyncMock() + ) as mock_register, patch( + "agentex.lib.core.temporal.workers.worker.assert_backend_compatible", + new=AsyncMock(), + ), patch( + "agentex.lib.core.temporal.workers.worker.EnvironmentVariables" + ) as mock_env_cls: + env = self._env_vars_mock() + mock_env_cls.refresh.return_value = env + + await worker._register_agent() + + mock_register.assert_awaited_once_with(env, agent_card=None) + + async def test_supplied_card_forwarded_exactly_once_by_run_lifecycle(self): + """A card passed to the constructor reaches register_agent exactly once + through the existing automatic registration in run(); no second + registration call is introduced.""" + from agentex.lib.types.agent_card import AgentCard + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + card = AgentCard(metadata={"permits_capable": True}) + worker = AgentexWorker( + task_queue="test-queue", health_check_port=8080, agent_card=card + ) + + with patch.object( + worker, "start_health_check_server", new=AsyncMock() + ), patch( + "agentex.lib.core.temporal.workers.worker.register_agent", new=AsyncMock() + ) as mock_register, patch( + "agentex.lib.core.temporal.workers.worker.assert_backend_compatible", + new=AsyncMock(), + ), patch( + "agentex.lib.core.temporal.workers.worker.EnvironmentVariables" + ) as mock_env_cls, patch( + "agentex.lib.core.temporal.workers.worker.get_temporal_client", + new=AsyncMock(return_value=MagicMock()), + ), patch( + "agentex.lib.core.temporal.workers.worker.Worker" + ) as mock_worker_cls: + env = self._env_vars_mock() + mock_env_cls.refresh.return_value = env + mock_worker_cls.return_value.run = AsyncMock() + + await worker.run(activities=[], workflows=[MagicMock()]) + + mock_register.assert_awaited_once_with(env, agent_card=card) + + async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self): + """The worker path and the FastACP/BaseACPServer lifespan path hand the + same card to register_agent, so the registration payload's + registration_metadata.agent_card is identical.""" + from agentex.lib.types.agent_card import AgentCard + from agentex.lib.core.temporal.workers.worker import AgentexWorker + from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + + card = AgentCard(metadata={"permits_capable": True, "region": "us"}) + + worker_payloads = [] + worker = AgentexWorker( + task_queue="test-queue", health_check_port=8080, agent_card=card + ) + with patch( + "agentex.lib.core.temporal.workers.worker.assert_backend_compatible", + new=AsyncMock(), + ), patch( + "agentex.lib.core.temporal.workers.worker.EnvironmentVariables" + ) as mock_env_cls, patch( + "agentex.lib.utils.registration.httpx.AsyncClient", + new=self._httpx_client_mock(worker_payloads), + ): + mock_env_cls.refresh.return_value = self._env_vars_mock() + await worker._register_agent() + + acp_payloads = [] + server = BaseACPServer.create() + server._agent_card = card + lifespan = server.get_lifespan_function() + with patch( + "agentex.lib.sdk.fastacp.base.base_acp_server.assert_backend_compatible", + new=AsyncMock(), + ), patch( + "agentex.lib.sdk.fastacp.base.base_acp_server.EnvironmentVariables" + ) as mock_env_cls, patch( + "agentex.lib.sdk.fastacp.base.base_acp_server.shutdown_default_span_queue", + new=AsyncMock(), + ), patch( + "agentex.lib.utils.registration.httpx.AsyncClient", + new=self._httpx_client_mock(acp_payloads), + ): + mock_env_cls.refresh.return_value = self._env_vars_mock() + async with lifespan(MagicMock()): + pass + + assert len(worker_payloads) == 1 + assert len(acp_payloads) == 1 + worker_card = worker_payloads[0]["registration_metadata"]["agent_card"] + acp_card = acp_payloads[0]["registration_metadata"]["agent_card"] + assert worker_card == acp_card == card.model_dump() + + class TestGetTemporalClientMetricsConfig: """Tests that metrics params reach OpenTelemetryConfig correctly.""" diff --git a/tests/lib/test_metadata_filters.py b/tests/lib/test_metadata_filters.py new file mode 100644 index 000000000..34398187f --- /dev/null +++ b/tests/lib/test_metadata_filters.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json + +import httpx +import respx +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.lib.utils.metadata_filters import encode_metadata_filter + +BASE_URL = "http://127.0.0.1:4010" +API_KEY = "My API Key" + + +class TestEncodeMetadataFilter: + def test_encodes_a_json_object(self) -> None: + assert encode_metadata_filter({"permits_capable": True}) == '{"permits_capable":true}' + + def test_empty_mapping_encodes_to_an_empty_object(self) -> None: + assert encode_metadata_filter({}) == "{}" + + def test_key_order_is_stable(self) -> None: + assert ( + encode_metadata_filter({"region": "us", "permits_capable": True}) + == encode_metadata_filter({"permits_capable": True, "region": "us"}) + == '{"permits_capable":true,"region":"us"}' + ) + + def test_preserves_json_types_and_nesting(self) -> None: + encoded = encode_metadata_filter({"flag": True, "count": 3, "ratio": 1.5, "nested": {"a": [1, "two", None]}}) + assert json.loads(encoded) == { + "flag": True, + "count": 3, + "ratio": 1.5, + "nested": {"a": [1, "two", None]}, + } + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_rejects_non_finite_floats(self, value: float) -> None: + # The server rejects these with a 400; fail locally with a clearer message. + with pytest.raises(ValueError, match="not encodable as JSON"): + encode_metadata_filter({"x": value}) + + def test_rejects_a_non_mapping(self) -> None: + with pytest.raises(TypeError, match="must be a mapping"): + encode_metadata_filter([("permits_capable", True)]) # type: ignore[arg-type] + + def test_rejects_a_non_serializable_value(self) -> None: + with pytest.raises(TypeError): + encode_metadata_filter({"x": object()}) + + +class TestAgentCardMetadataOnTheWire: + """The encoded filter has to survive the client's query-string serialization. + + The generated `agents.list` parameter is a plain `str` (the platform spec + declares a JSON-encoded string, matching the shipped `task_metadata` + filter), so these assert the exact query value the server will parse. + """ + + @respx.mock(base_url=BASE_URL) + def test_sync_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}), + limit=5, + ) + + params = route.calls.last.request.url.params + raw = params["agent_card_metadata"] + assert raw == '{"permits_capable":true,"region":"us"}' + assert json.loads(raw) == {"permits_capable": True, "region": "us"} + assert params["limit"] == "5" + + @respx.mock(base_url=BASE_URL) + async def test_async_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + async with AsyncAgentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + await client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}), + limit=5, + ) + + params = route.calls.last.request.url.params + raw = params["agent_card_metadata"] + assert raw == '{"permits_capable":true,"region":"us"}' + assert json.loads(raw) == {"permits_capable": True, "region": "us"} + assert params["limit"] == "5" + + @respx.mock(base_url=BASE_URL) + def test_omitted_filter_is_absent_from_the_query(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list() + + assert "agent_card_metadata" not in route.calls.last.request.url.params + + @respx.mock(base_url=BASE_URL) + def test_empty_object_filter_is_sent_verbatim(self, respx_mock: respx.MockRouter) -> None: + """`{}` is a meaningful filter server-side (agent must have card metadata), + so it must reach the wire rather than being dropped as falsy.""" + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list(agent_card_metadata=encode_metadata_filter({})) + + assert route.calls.last.request.url.params["agent_card_metadata"] == "{}" From 635ca8d986a7c5bc5bf283c745d67d650d4c8d21 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Mon, 14 Sep 2026 14:32:52 -0400 Subject: [PATCH 8/9] chore: add 0.26.0 changelog entries for #502 and #509 --- CHANGELOG.md | 8 +++++++- adk/CHANGELOG.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a09e7cf13..f8ffe61e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,20 @@ * **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``. -## 0.26.0 (2026-09-01) +## 0.26.0 (2026-09-14) Full Changelog: [agentex-client-v0.25.0...agentex-client-v0.26.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.25.0...agentex-client-v0.26.0) ### Features +* **agent-card:** add metadata field and expose list filter ([#502](https://github.com/scaleapi/scale-agentex-python/issues/502)) ([55a0178](https://github.com/scaleapi/scale-agentex-python/commit/55a0178c2d210325770b56dbb58eac2f644cc14b)) +* **api:** add agent_card_metadata parameter to agents list method ([ebb632a](https://github.com/scaleapi/scale-agentex-python/commit/ebb632a4b22287e157bcf086dd98b27f26188fc5)) * **tracing:** add opt-in commit SHA stamping for SGP spans ([#505](https://github.com/scaleapi/scale-agentex-python/issues/505)) ([76252a9](https://github.com/scaleapi/scale-agentex-python/commit/76252a98f28663e8c95777456d07e42171592c62)) +### Bug Fixes + +* keep agent output streaming alive on an unreadable line, and honor LOG_LEVEL ([#509](https://github.com/scaleapi/scale-agentex-python/issues/509)) ([0db6037](https://github.com/scaleapi/scale-agentex-python/commit/0db6037e63ca1b24b80ae0d38883f7687ae5b9e5)) + ## 0.25.0 (2026-08-26) Full Changelog: [agentex-client-v0.24.0...agentex-client-v0.25.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.24.0...agentex-client-v0.25.0) diff --git a/adk/CHANGELOG.md b/adk/CHANGELOG.md index a07f93249..3b2cc8d62 100644 --- a/adk/CHANGELOG.md +++ b/adk/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.26.0 (2026-09-01) +## 0.26.0 (2026-09-14) Full Changelog: [agentex-sdk-v0.25.0...agentex-sdk-v0.26.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.25.0...agentex-sdk-v0.26.0) From 4411001f34ac6660e12092b263b516a9568e3dc7 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Mon, 14 Sep 2026 15:34:17 -0400 Subject: [PATCH 9/9] chore: retrigger CI after dropped push event