diff --git a/adk/README.md b/adk/README.md index 206ba993b..ef7c553d9 100644 --- a/adk/README.md +++ b/adk/README.md @@ -27,6 +27,26 @@ This automatically pulls in [`agentex-client`](../) (the slim Stainless-generate The two packages contribute disjoint files to the `agentex.*` namespace — `agentex/lib/*` ships only from `agentex-sdk`. +## Workflow logging + +Use the workflow logger in Temporal workflow code: + +```python +from agentex.lib.core.temporal.logging import make_workflow_logger + +logger = make_workflow_logger(__name__) +``` + +It suppresses logs while Temporal replays recorded history and adds top-level +`workflow_id` and `run_id` fields during workflow execution. It preserves the +message, caller fields, and exception details. Outside workflows, including in +activities, it behaves like the ordinary SDK logger. + +New Temporal templates use this helper. Existing agents must replace their own +workflow loggers to get the same behavior. This does not create trace context or +add trace IDs to workflows that lack it. Temporal's worker diagnostics still report +replay failures. + ## Repo layout This package is hand-authored and lives at `adk/` inside [scaleapi/scale-agentex-python](https://github.com/scaleapi/scale-agentex-python). Stainless codegen never touches `adk/**` — it's outside the generated surface. The sibling `agentex-client` package lives at the repo root and IS Stainless-generated. diff --git a/src/agentex/lib/adk/utils/_modules/client.py b/src/agentex/lib/adk/utils/_modules/client.py index 725289631..5312b7b6a 100644 --- a/src/agentex/lib/adk/utils/_modules/client.py +++ b/src/agentex/lib/adk/utils/_modules/client.py @@ -1,3 +1,4 @@ +import os from typing import override import httpx @@ -26,7 +27,50 @@ def auth_flow(self, request): yield request +# HTTP timeouts for the AgentEx client, in seconds. Defaults match the SDK's +# DEFAULT_TIMEOUT, so leaving these unset changes nothing. +_TIMEOUT_ENV_DEFAULTS = { + "connect": ("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", 5.0), + "read": ("AGENTEX_CLIENT_READ_TIMEOUT_SECONDS", 300.0), + "write": ("AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS", 300.0), + "pool": ("AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS", 300.0), +} + + +def _timeout_from_env() -> httpx.Timeout: + """Build the client timeout from environment variables. + + Read from ``os.environ`` rather than from ``EnvironmentVariables``. That model + is loaded by worker startup and by ``EnvAuth.auth_flow`` on every request, and + ``agentex.lib.adk.utils`` builds a client at import time, so a field added + there would make a malformed timeout break all three. Reading here keeps the + blast radius to the one value that is actually wrong. + + The connect timeout is the one worth raising: an AgentEx backend accepts + connections serially, so connect latency grows with the number of callers and + the 5s default is reached when a few hundred are in flight. + """ + values = {} + for field, (env_var, default) in _TIMEOUT_ENV_DEFAULTS.items(): + raw = os.environ.get(env_var) + if raw is None or raw.strip() == "": + values[field] = default + continue + try: + values[field] = float(raw) + except ValueError as exc: + raise ValueError(f"{env_var} must be a number in seconds, got {raw!r}") from exc + return httpx.Timeout(**values) + + def create_async_agentex_client(**kwargs) -> AsyncAgentex: + """Create an AsyncAgentex client. + + An explicit ``timeout=`` always wins; otherwise the timeout comes from the + AGENTEX_CLIENT_*_TIMEOUT_SECONDS environment variables. + """ + if "timeout" not in kwargs: + kwargs["timeout"] = _timeout_from_env() client = AsyncAgentex(**kwargs) client._client.auth = EnvAuth() return client diff --git a/src/agentex/lib/cli/handlers/deploy_handlers.py b/src/agentex/lib/cli/handlers/deploy_handlers.py index 605d91709..e1cd1965c 100644 --- a/src/agentex/lib/cli/handlers/deploy_handlers.py +++ b/src/agentex/lib/cli/handlers/deploy_handlers.py @@ -389,6 +389,8 @@ def merge_deployment_configs( _deep_merge(helm_values, agent_env_config.helm_overrides) logger.info(f"After-merge helm values: {helm_values}") + _stamp_agent_version(helm_values, set(all_env_vars) | {var["name"] for var in secret_env_vars}) + # Set final environment variables # Environment variable precedence: manifest -> environments.yaml -> secrets (highest) if all_env_vars: @@ -430,6 +432,14 @@ def _deep_merge(base_dict: dict[str, Any], override_dict: dict[str, Any]) -> Non base_dict[key] = value +def _stamp_agent_version(helm_values: dict[str, Any], declared_env_names: set[str]) -> None: + """Set global.agent.version from the merged image tag unless the deployment declares AGENT_VERSION itself.""" + if EnvVarKeys.AGENT_VERSION.value in declared_env_names: + # Chart >=0.6.0 renders global.agent.version as a second AGENT_VERSION env entry. + return + helm_values["global"]["agent"].setdefault("version", helm_values["global"]["image"]["tag"]) + + def create_helm_values_file(helm_values: dict[str, Any]) -> str: """Create a temporary helm values file""" with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 index 8191ad80f..108316ab9 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 @@ -27,7 +27,7 @@ from temporalio import workflow from agentex.lib import adk from agentex.lib.types.acp import SendEventParams, CreateTaskParams from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agentex.lib.core.temporal.types.workflow import SignalName @@ -52,7 +52,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 index 1004ebfb8..9890efab8 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 @@ -29,7 +29,7 @@ from temporalio import workflow from agentex.lib import adk from agentex.lib.types.acp import SendEventParams, CreateTaskParams from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agentex.lib.core.temporal.types.workflow import SignalName @@ -54,7 +54,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) MODEL = os.environ.get("CODEX_MODEL", "o4-mini") diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 index 14bafabc1..d0db42bc8 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 @@ -37,7 +37,7 @@ from project.graph import GRAPH_NAME, build_graph from agentex.lib.adk import emit_langgraph_messages from agentex.protocol.acp import SendEventParams, CreateTaskParams from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agentex.lib.core.temporal.types.workflow import SignalName @@ -64,7 +64,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 index af8b7a299..207984ce9 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 @@ -7,7 +7,7 @@ from agentex.lib import adk from agentex.protocol.acp import CreateTaskParams, SendEventParams from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agents import Agent, Runner, set_tracing_disabled @@ -37,7 +37,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) # Setup tracing for SGP (Scale GenAI Platform) # This enables visibility into your agent's execution in the SGP dashboard diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 index 6dcca3002..0f25e961c 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 @@ -25,7 +25,7 @@ from project.agent import TaskDeps, temporal_agent from agentex.lib import adk from agentex.protocol.acp import SendEventParams, CreateTaskParams from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agentex.lib.core.temporal.types.workflow import SignalName @@ -55,7 +55,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 index 56db5abf3..8c23ecfc1 100644 --- a/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 @@ -6,7 +6,7 @@ from agentex.lib import adk from agentex.protocol.acp import CreateTaskParams, SendEventParams from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables @@ -18,7 +18,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) class {{ workflow_class }}(BaseWorkflow): diff --git a/src/agentex/lib/core/temporal/logging.py b/src/agentex/lib/core/temporal/logging.py new file mode 100644 index 000000000..094388525 --- /dev/null +++ b/src/agentex/lib/core/temporal/logging.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any, override +from collections.abc import MutableMapping + +from temporalio import workflow + +from agentex.lib.utils.logging import make_logger + + +class WorkflowLoggerAdapter(workflow.LoggerAdapter): + """Skip workflow replay logs and add IDs without changing non-workflow logs.""" + + @override + def isEnabledFor(self, level: int) -> bool: + if not workflow.in_workflow(): + return self.logger.isEnabledFor(level) + return super().isEnabledFor(level) + + @override + def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]: + if workflow.in_workflow(): + info = workflow.info() + kwargs["extra"] = { + "workflow_id": info.workflow_id, + "run_id": info.run_id, + **(kwargs.get("extra") or {}), + } + return msg, kwargs + + +def make_workflow_logger(name: str) -> WorkflowLoggerAdapter: + """Create an SDK logger that suppresses replay and adds workflow/run IDs.""" + return WorkflowLoggerAdapter(make_logger(name), {}) diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py index 893f75f28..26dce2994 100644 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py @@ -22,8 +22,10 @@ ) from temporalio.converter import default +from agentex.lib.core.temporal.logging import WorkflowLoggerAdapter + # Set up logging -logger = logging.getLogger("context.interceptor") +logger = WorkflowLoggerAdapter(logging.getLogger("context.interceptor"), {}) # Global context variables that models can read # These are thread-safe and work across async boundaries diff --git a/src/agentex/lib/core/temporal/workers/worker.py b/src/agentex/lib/core/temporal/workers/worker.py index 72631917f..4e7e7b6a5 100644 --- a/src/agentex/lib/core/temporal/workers/worker.py +++ b/src/agentex/lib/core/temporal/workers/worker.py @@ -258,9 +258,8 @@ async def run( max_concurrent_activities=self.max_concurrent_activities, build_id=str(uuid.uuid4()), debug_mode=debug_enabled, # Disable deadlock detection in debug mode - # Tracing interceptor OUTERMOST so business interceptors (and the spans - # they create) nest under the propagated workflow/activity span. - interceptors=[*temporal_tracing_interceptors(), *self.interceptors], + # Temporal inherits client tracing before these business interceptors. + interceptors=self.interceptors, ) logger.info(f"Starting workers for task queue: {self.task_queue}") diff --git a/src/agentex/lib/core/temporal/workflows/workflow.py b/src/agentex/lib/core/temporal/workflows/workflow.py index e47fd9a5c..8b638cf8a 100644 --- a/src/agentex/lib/core/temporal/workflows/workflow.py +++ b/src/agentex/lib/core/temporal/workflows/workflow.py @@ -7,10 +7,10 @@ from temporalio import workflow from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.lib.core.temporal.types.workflow import SignalName -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) class BaseWorkflow(ABC): diff --git a/src/agentex/lib/core/tracing/code_revision.py b/src/agentex/lib/core/tracing/code_revision.py index 7b08dd45f..570d4f1cd 100644 --- a/src/agentex/lib/core/tracing/code_revision.py +++ b/src/agentex/lib/core/tracing/code_revision.py @@ -1,10 +1,11 @@ -"""Opt-in stamping of the agent's source commit onto its spans. +"""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__:``. +Stamping turns on when the process starts with ``AGENT_COMMIT_SHA`` set, which +the SGP cloud deploy does from the build record's attested commit, or when the +agent calls :func:`enable` itself. Nothing is stamped otherwise: upgrading the +SDK alone never starts emitting the field. When on, 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 @@ -21,7 +22,7 @@ from agentex.lib.utils.logging import make_logger -__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha") +__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha", "is_git_object_name") logger = make_logger(__name__) @@ -31,6 +32,12 @@ # git's own 7-character minimum. _GIT_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}") + +def is_git_object_name(value: str) -> bool: + """Whether ``value`` is a full or abbreviated git SHA-1/SHA-256 object name.""" + return _GIT_SHA_RE.fullmatch(value.strip()) is not None + + _COMMIT_SHA_ENV = "AGENT_COMMIT_SHA" # Fallback only: automatic, and only usable when it happens to be SHA-shaped. _AGENT_VERSION_ENV = "AGENT_VERSION" @@ -42,13 +49,16 @@ def enable(commit_sha: str | None = None) -> None: - """Opt this process in to stamping ``__commit_sha__`` onto every span. + """Turn on stamping ``__commit_sha__`` onto every span from this process. 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. + + Called once at import when ``AGENT_COMMIT_SHA`` is set, so a deployment that + supplies the commit needs no code change in the agent. """ global _commit_sha @@ -103,3 +113,12 @@ def is_enabled() -> bool: def commit_sha() -> str | None: """The resolved commit SHA, or ``None`` when stamping is not enabled.""" return _commit_sha + + +def _enable_from_environment() -> None: + """Auto-enable on ``AGENT_COMMIT_SHA`` only; ``AGENT_VERSION`` stays an explicit fallback.""" + if os.environ.get(_COMMIT_SHA_ENV, "").strip(): + enable() + + +_enable_from_environment() diff --git a/src/agentex/lib/environment_variables.py b/src/agentex/lib/environment_variables.py index 00dbbaada..dae1e5db3 100644 --- a/src/agentex/lib/environment_variables.py +++ b/src/agentex/lib/environment_variables.py @@ -26,6 +26,7 @@ class EnvVarKeys(str, Enum): AGENT_ID = "AGENT_ID" AGENT_VERSION = "AGENT_VERSION" AGENT_COMMIT_SHA = "AGENT_COMMIT_SHA" + AGENT_SOURCE_REPO = "AGENT_SOURCE_REPO" AGENT_API_KEY = "AGENT_API_KEY" # ACP Configuration ACP_URL = "ACP_URL" @@ -68,12 +69,11 @@ 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. + # The agent's source commit, set by the deployment or baked into the image; a git + # SHA and nothing else. Stamped as __commit_sha__ when set (see tracing.code_revision). AGENT_COMMIT_SHA: str | None = None + # Git remote the agent was built from (any URL form; normalized to host/path on use). + AGENT_SOURCE_REPO: str | None = None AGENT_API_KEY: str | None = None ACP_TYPE: str | None = "async" AGENT_INPUT_TYPE: str | None = None diff --git a/src/agentex/lib/utils/build_provenance.py b/src/agentex/lib/utils/build_provenance.py index 447980263..37b61a3f9 100644 --- a/src/agentex/lib/utils/build_provenance.py +++ b/src/agentex/lib/utils/build_provenance.py @@ -82,7 +82,8 @@ def normalize_remote(url: Optional[str]) -> Optional[str]: """Strip credentials and scheme from a remote, returning ``host/path``.""" if not url: return None - candidate = url.strip() + # Query strings and fragments never name a repo, but they do carry tokens. + candidate = url.strip().split("?", 1)[0].split("#", 1)[0] # scp-like syntax: git@host:org/repo(.git) — no scheme, host/path split on ':' if "://" not in candidate and ":" in candidate and "/" not in candidate.split(":", 1)[0]: candidate = candidate.split("@", 1)[-1].replace(":", "/", 1) diff --git a/src/agentex/lib/utils/registration.py b/src/agentex/lib/utils/registration.py index 5fc4d4be5..36b5f9a04 100644 --- a/src/agentex/lib/utils/registration.py +++ b/src/agentex/lib/utils/registration.py @@ -7,6 +7,8 @@ from agentex.lib.utils.logging import make_logger from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.utils.build_provenance import normalize_remote +from agentex.lib.core.tracing.code_revision import is_git_object_name logger = make_logger(__name__) @@ -20,6 +22,29 @@ def get_auth_principal(env_vars: EnvironmentVariables): except Exception: return None + +def build_registration_metadata(env_vars: EnvironmentVariables, agent_card=None) -> dict: + """Deployment id, source provenance, and agent card; keys appear only when known.""" + metadata: dict = {} + if env_vars.AGENTEX_DEPLOYMENT_ID: + metadata["deployment_id"] = env_vars.AGENTEX_DEPLOYMENT_ID + commit = (env_vars.AGENT_COMMIT_SHA or "").strip() + if commit: + if is_git_object_name(commit): + metadata["commit_sha"] = commit + else: + logger.warning( + "AGENT_COMMIT_SHA=%r is not a git commit SHA; commit_sha omitted from registration.", + commit, + ) + repo = normalize_remote(env_vars.AGENT_SOURCE_REPO) + if repo: + metadata["source_repo"] = repo + if agent_card is not None: + metadata["agent_card"] = agent_card.model_dump() if hasattr(agent_card, "model_dump") else agent_card + return metadata + + async def register_agent(env_vars: EnvironmentVariables, agent_card=None): """Register this agent with the Agentex server""" if not env_vars.AGENTEX_BASE_URL: @@ -33,13 +58,7 @@ async def register_agent(env_vars: EnvironmentVariables, agent_card=None): or f"Generic description for agent: {env_vars.AGENT_NAME}" ) - # Registration metadata carries the deployment id and agent card. - registration_metadata: dict = {} - if env_vars.AGENTEX_DEPLOYMENT_ID: - registration_metadata["deployment_id"] = env_vars.AGENTEX_DEPLOYMENT_ID - if agent_card is not None: - card_data = agent_card.model_dump() if hasattr(agent_card, "model_dump") else agent_card - registration_metadata["agent_card"] = card_data + registration_metadata = build_registration_metadata(env_vars, agent_card) # Prepare registration data registration_data = { diff --git a/tests/lib/cli/test_deploy_handlers.py b/tests/lib/cli/test_deploy_handlers.py new file mode 100644 index 000000000..835b56ae8 --- /dev/null +++ b/tests/lib/cli/test_deploy_handlers.py @@ -0,0 +1,64 @@ +"""Tests for the helm values merge_deployment_configs assembles for `agentex agents deploy`.""" + +from __future__ import annotations + +from typing import Any + +from agentex.config.agent_config import AgentConfig +from agentex.config.build_config import BuildConfig, BuildContext +from agentex.config.agent_manifest import AgentManifest +from agentex.config.deployment_config import ImageConfig, DeploymentConfig +from agentex.config.environment_config import AgentAuthConfig, AgentEnvironmentConfig +from agentex.lib.cli.handlers.deploy_handlers import InputDeployOverrides, merge_deployment_configs + +MANIFEST_TAG = "sha-manifest" + + +def _manifest(env: dict[str, str] | None = None) -> AgentManifest: + return AgentManifest( + build=BuildConfig(context=BuildContext(root=".", dockerfile="Dockerfile", dockerignore=None)), + agent=AgentConfig(name="emu-tax", description="Files emu taxes", acp_type="async", env=env), + deployment=DeploymentConfig(image=ImageConfig(repository="registry.example.com/emu-tax", tag=MANIFEST_TAG)), + ) + + +def _env_config(helm_overrides: dict[str, Any]) -> AgentEnvironmentConfig: + return AgentEnvironmentConfig(auth=AgentAuthConfig(principal={"user_id": "u-1"}), helm_overrides=helm_overrides) + + +def _merge( + manifest: AgentManifest, + env_config: AgentEnvironmentConfig | None = None, + image_tag: str | None = None, +) -> dict[str, Any]: + overrides = InputDeployOverrides(image_tag=image_tag) + return merge_deployment_configs(manifest, env_config, overrides, "/nonexistent/manifest.yaml") + + +class TestAgentVersion: + def test_stamped_from_the_deploy_image_tag(self): + values = _merge(_manifest(), image_tag="sha-cli") + + assert values["global"]["agent"]["version"] == "sha-cli" + + def test_follows_an_image_tag_overridden_in_helm_overrides(self): + values = _merge(_manifest(), _env_config({"global": {"image": {"tag": "sha-env"}}})) + + assert values["global"]["image"]["tag"] == "sha-env" + assert values["global"]["agent"]["version"] == "sha-env" + + def test_explicit_helm_override_of_the_version_wins(self): + values = _merge(_manifest(), _env_config({"global": {"agent": {"version": "pinned"}}})) + + assert values["global"]["agent"]["version"] == "pinned" + + def test_skipped_when_the_manifest_env_declares_agent_version(self): + values = _merge(_manifest(env={"AGENT_VERSION": "v1.2.3"})) + + assert "version" not in values["global"]["agent"] + assert {"name": "AGENT_VERSION", "value": "v1.2.3"} in values["env"] + + def test_skipped_when_the_environment_env_declares_agent_version(self): + values = _merge(_manifest(), _env_config({"env": [{"name": "AGENT_VERSION", "value": "v9"}]})) + + assert "version" not in values["global"]["agent"] diff --git a/tests/lib/core/temporal/test_workflow_logging.py b/tests/lib/core/temporal/test_workflow_logging.py new file mode 100644 index 000000000..6b193b35e --- /dev/null +++ b/tests/lib/core/temporal/test_workflow_logging.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import logging +from types import SimpleNamespace + +import pytest +from temporalio import workflow +from temporalio.testing import ActivityEnvironment + +from agentex.lib.core.temporal.workflows import workflow as base_workflow +from agentex.lib.core.temporal.plugins.openai_agents.interceptors import context_interceptor + + +@pytest.fixture(params=[base_workflow.logger, context_interceptor.logger], ids=["base-workflow", "context-interceptor"]) +def sdk_logger(request, caplog): + logger = request.param + caplog.set_level(logging.DEBUG, logger=logger.name) + return logger + + +@pytest.fixture +def workflow_context(monkeypatch): + def set_context(*, replaying: bool) -> None: + monkeypatch.setattr(workflow, "in_workflow", lambda: True) + monkeypatch.setattr(workflow, "info", lambda: SimpleNamespace(workflow_id="task-123", run_id="run-456")) + replay_check = ( + "is_replaying_history_events" if hasattr(workflow.unsafe, "is_replaying_history_events") else "is_replaying" + ) + monkeypatch.setattr(workflow.unsafe, replay_check, lambda: replaying) + + return set_context + + +@pytest.mark.parametrize("level", [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR]) +def test_sdk_workflow_logs_are_suppressed_during_replay(sdk_logger, workflow_context, caplog, level): + workflow_context(replaying=True) + + sdk_logger.log(level, "Repeated workflow operation") + + assert not caplog.records + + +def test_sdk_workflow_logs_include_ids_and_preserve_caller_fields(sdk_logger, workflow_context, caplog): + workflow_context(replaying=False) + fields = {"operation": "interrupt", "trace_id": "existing-trace", "span_id": "existing-span"} + + sdk_logger.info("Handling %s", "interrupt", extra=fields) + + (record,) = caplog.records + assert record.workflow_id == "task-123" + assert record.run_id == "run-456" + assert record.operation == "interrupt" + assert record.trace_id == "existing-trace" + assert record.span_id == "existing-span" + assert record.getMessage() == "Handling interrupt" + assert record.pathname == __file__ + assert "temporal_workflow" not in record.__dict__ + assert fields == {"operation": "interrupt", "trace_id": "existing-trace", "span_id": "existing-span"} + + +def test_workflow_logs_without_trace_context_do_not_invent_ids(sdk_logger, workflow_context, caplog): + workflow_context(replaying=False) + + sdk_logger.info("Workflow without a trace") + + (record,) = caplog.records + assert record.workflow_id == "task-123" + assert record.run_id == "run-456" + assert "trace_id" not in record.__dict__ + assert "span_id" not in record.__dict__ + + +@pytest.mark.parametrize("in_activity", [False, True], ids=["startup", "activity"]) +def test_sdk_logger_works_outside_workflows(sdk_logger, caplog, in_activity): + def log_message(): + sdk_logger.info("Outside workflow", extra={"operation": "startup"}) + + if in_activity: + ActivityEnvironment().run(log_message) + else: + log_message() + + (record,) = caplog.records + assert record.getMessage() == "Outside workflow" + assert record.operation == "startup" + assert "workflow_id" not in record.__dict__ + assert "run_id" not in record.__dict__ + + +def test_sdk_workflow_logger_preserves_exception_details(sdk_logger, workflow_context, caplog): + workflow_context(replaying=False) + + try: + raise ValueError("operation failed") + except ValueError: + sdk_logger.exception("Workflow operation failed") + + (record,) = caplog.records + assert record.exc_info is not None + assert isinstance(record.exc_info[1], ValueError) + assert record.workflow_id == "task-123" + assert record.run_id == "run-456" diff --git a/tests/lib/core/temporal/test_workflow_logging_replay.py b/tests/lib/core/temporal/test_workflow_logging_replay.py new file mode 100644 index 000000000..3774d3689 --- /dev/null +++ b/tests/lib/core/temporal/test_workflow_logging_replay.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor + +import pytest +from temporalio import workflow +from temporalio.client import WorkflowHistory +from temporalio.worker import Replayer + +with workflow.unsafe.imports_passed_through(): + from agentex.lib.core.temporal.workflows import workflow as base_workflow + + +@workflow.defn +class ReplayLoggingWorkflow: + @workflow.run + async def run(self) -> None: + base_workflow.logger.info("SDK workflow replay log") + + +def completed_history() -> WorkflowHistory: + return WorkflowHistory.from_json( + "replay-logging-workflow", + { + "events": [ + { + "eventId": "1", + "eventTime": "2026-09-18T00:00:00Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "workflowExecutionStartedEventAttributes": { + "workflowType": {"name": "ReplayLoggingWorkflow"}, + "taskQueue": {"name": "replay-logging-queue"}, + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "806b1959-3829-42a6-a32b-2623ea410033", + }, + }, + { + "eventId": "2", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "workflowTaskScheduledEventAttributes": { + "taskQueue": {"name": "replay-logging-queue"}, + "startToCloseTimeout": "10s", + "attempt": 1, + }, + }, + { + "eventId": "3", + "eventTime": "2026-09-18T00:00:00Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "workflowTaskStartedEventAttributes": {"scheduledEventId": "2"}, + }, + { + "eventId": "4", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "workflowTaskCompletedEventAttributes": {"scheduledEventId": "2", "startedEventId": "3"}, + }, + { + "eventId": "5", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", + "workflowExecutionCompletedEventAttributes": {"workflowTaskCompletedEventId": "4"}, + }, + ], + }, + ) + + +async def test_sdk_logger_suppresses_real_workflow_replay(caplog, monkeypatch: pytest.MonkeyPatch) -> None: + caplog.set_level(logging.INFO, logger=base_workflow.logger.name) + with ThreadPoolExecutor(max_workers=1) as executor: + replayer = Replayer(workflows=[ReplayLoggingWorkflow], workflow_task_executor=executor) + + with monkeypatch.context() as patch: + patch.setattr(base_workflow, "logger", logging.getLogger(base_workflow.logger.name)) + await replayer.replay_workflow(completed_history()) + + assert [record.getMessage() for record in caplog.records] == ["SDK workflow replay log"] + caplog.clear() + + await replayer.replay_workflow(completed_history()) + + assert not caplog.records diff --git a/tests/lib/core/temporal/workers/test_worker_tracing.py b/tests/lib/core/temporal/workers/test_worker_tracing.py new file mode 100644 index 000000000..0242fd01b --- /dev/null +++ b/tests/lib/core/temporal/workers/test_worker_tracing.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import dataclasses +from typing import Any, override +from unittest.mock import Mock, AsyncMock + +import pytest +from temporalio import activity +from opentelemetry import trace +from temporalio.worker import Worker, Interceptor, ExecuteActivityInput, ActivityInboundInterceptor +from temporalio.testing import ActivityEnvironment +from opentelemetry.sdk.trace import TracerProvider +from temporalio.bridge.client import Client as BridgeClient +from temporalio.bridge.worker import Worker as BridgeWorker +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from temporalio.contrib.opentelemetry import TracingInterceptor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from agentex.lib.core.temporal.workers.worker import AgentexWorker + + +class _BusinessInterceptor(Interceptor): + def __init__(self, name: str, events: list[tuple[str, bool]]) -> None: + self.name = name + self.events = events + + @override + def intercept_activity(self, next: ActivityInboundInterceptor) -> ActivityInboundInterceptor: + owner = self + + class Inbound(ActivityInboundInterceptor): + @override + async def execute_activity(self, input: ExecuteActivityInput) -> Any: + owner.events.append((owner.name, trace.get_current_span().get_span_context().is_valid)) + return await self.next.execute_activity(input) + + return Inbound(next) + + +class _ActivityCall(ActivityInboundInterceptor): + def __init__(self) -> None: + pass + + @override + async def execute_activity(self, input: ExecuteActivityInput) -> Any: + return await input.fn(*input.args) + + +@pytest.mark.parametrize("tracing_enabled", [True, False]) +async def test_worker_inherits_one_tracing_interceptor_before_business_interceptors( + monkeypatch: pytest.MonkeyPatch, tracing_enabled: bool +) -> None: + monkeypatch.setenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", str(tracing_enabled).lower()) + monkeypatch.delenv("DD_AGENT_HOST", raising=False) + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tracer = provider.get_tracer(__name__) + monkeypatch.setattr(trace, "get_tracer", lambda *args, **kwargs: tracer) + # Keep Client and Worker configuration real; replace their network boundary. + monkeypatch.setattr(BridgeClient, "connect", AsyncMock(return_value=Mock())) + monkeypatch.setattr(BridgeWorker, "create", Mock(return_value=Mock())) + + events: list[tuple[str, bool]] = [] + first = _BusinessInterceptor("first", events) + second = _BusinessInterceptor("second", events) + + @activity.defn + async def sample_activity() -> str: + events.append(("activity", trace.get_current_span().get_span_context().is_valid)) + return "completed" + + async def run_once(worker: Worker) -> None: + assert worker._activity_worker is not None + interceptors = worker._activity_worker._interceptors + assert sum(isinstance(item, TracingInterceptor) for item in interceptors) == int(tracing_enabled) + assert list(interceptors[-2:]) == [first, second] + + inbound: ActivityInboundInterceptor = _ActivityCall() + for interceptor in reversed(interceptors): + inbound = interceptor.intercept_activity(inbound) + environment = ActivityEnvironment() + environment.info = dataclasses.replace(environment.info, activity_type="sample_activity") + result = await environment.run( + inbound.execute_activity, + ExecuteActivityInput(fn=sample_activity, args=[], executor=None, headers={}), + ) + assert result == "completed" + + monkeypatch.setattr(Worker, "run", run_once) + worker = AgentexWorker(task_queue="test-tracing", health_check_port=8080, interceptors=[first, second]) + monkeypatch.setattr(worker, "start_health_check_server", AsyncMock()) + monkeypatch.setattr(worker, "_register_agent", AsyncMock()) + + try: + await worker.run(activities=[sample_activity], workflows=[]) + assert events == [("first", tracing_enabled), ("second", tracing_enabled), ("activity", tracing_enabled)] + spans = exporter.get_finished_spans() + assert len(spans) == int(tracing_enabled) + if tracing_enabled: + assert spans[0].name == "RunActivity:sample_activity" + finally: + provider.shutdown() 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 6cd324f01..7b5c129d6 100644 --- a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py +++ b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py @@ -56,13 +56,13 @@ def test_agent_identity_and_version_stamped_into_span_data(self): 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.""" + def test_commit_sha_is_not_stamped_when_env_absent(self, monkeypatch): + """Upgrading the SDK must not start emitting __commit_sha__ on its own; + only AGENT_COMMIT_SHA or an enable() call turns it on.""" 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) + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) code_revision.disable() span = _make_span(); span.data = {} diff --git a/tests/lib/core/tracing/test_code_revision.py b/tests/lib/core/tracing/test_code_revision.py index 0b89b88f2..a696129d7 100644 --- a/tests/lib/core/tracing/test_code_revision.py +++ b/tests/lib/core/tracing/test_code_revision.py @@ -1,7 +1,8 @@ -"""Opt-in commit-SHA stamping. +"""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. +The contract that matters: with ``AGENT_COMMIT_SHA`` absent and no ``enable()`` +call, nothing is stamped, so upgrading the SDK never starts emitting this field +on its own. A deployment that sets the env var turns it on without agent code. """ from __future__ import annotations @@ -21,14 +22,33 @@ def _reset(): 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) +class TestEnablement: + def test_off_when_env_absent(self, monkeypatch): + """The import-time hook ignores AGENT_VERSION; that fallback needs enable().""" + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) monkeypatch.setenv("AGENT_VERSION", SHA) + code_revision._enable_from_environment() assert code_revision.commit_sha() is None assert code_revision.is_enabled() is False + def test_env_set_at_startup_enables_without_a_call(self, monkeypatch): + """The cloud deploy sets AGENT_COMMIT_SHA from the build record; the agent + should not need to know.""" + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision._enable_from_environment() + assert code_revision.commit_sha() == SHA + + def test_env_set_after_import_needs_enable(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + assert code_revision.commit_sha() is None + code_revision.enable() + assert code_revision.commit_sha() == SHA + + def test_bad_env_at_startup_leaves_it_off(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", "latest") + code_revision._enable_from_environment() + assert code_revision.commit_sha() is None + def test_enable_reads_agent_commit_sha(self, monkeypatch): monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) code_revision.enable() diff --git a/tests/lib/test_agent_card.py b/tests/lib/test_agent_card.py index f9a99ffc5..7246d7c32 100644 --- a/tests/lib/test_agent_card.py +++ b/tests/lib/test_agent_card.py @@ -377,6 +377,8 @@ def mock_env_vars(self): "AGENT_ID": None, "AGENT_INPUT_TYPE": None, "AGENT_API_KEY": None, + "AGENT_COMMIT_SHA": None, + "AGENT_SOURCE_REPO": None, "AGENTEX_DEPLOYMENT_ID": None, })() return mock diff --git a/tests/lib/test_agentex_worker.py b/tests/lib/test_agentex_worker.py index 742ac3e74..b0bf47a63 100644 --- a/tests/lib/test_agentex_worker.py +++ b/tests/lib/test_agentex_worker.py @@ -140,6 +140,8 @@ def _env_vars_mock(): env.AGENTEX_DEPLOYMENT_ID = None env.AGENT_ID = None env.AGENT_INPUT_TYPE = None + env.AGENT_COMMIT_SHA = None + env.AGENT_SOURCE_REPO = None return env @staticmethod diff --git a/tests/lib/test_build_provenance.py b/tests/lib/test_build_provenance.py index 9115e2804..1bf3629d0 100644 --- a/tests/lib/test_build_provenance.py +++ b/tests/lib/test_build_provenance.py @@ -50,6 +50,7 @@ def _write(root: Path, rel: str, content: str = "x") -> None: ("https://github.com/scaleapi/Repo.git", "github.com/scaleapi/Repo"), ("https://x-token:secret@GitHub.com/scaleapi/Repo", "github.com/scaleapi/Repo"), ("ssh://git@gitlab.com/group/sub/proj.git", "gitlab.com/group/sub/proj"), + ("https://github.com/scaleapi/Repo.git?access_token=SECRET#frag", "github.com/scaleapi/Repo"), ("", None), (None, None), ], diff --git a/tests/lib/test_client_timeout_env.py b/tests/lib/test_client_timeout_env.py new file mode 100644 index 000000000..c0d2140a1 --- /dev/null +++ b/tests/lib/test_client_timeout_env.py @@ -0,0 +1,102 @@ +"""Timeouts for the AgentEx client are configurable by environment variable. + +The connect timeout is the one that matters in practice. An AgentEx backend +accepts connections serially, so connect latency grows with the number of +concurrent callers, and the 5s default is reached once a few hundred are in +flight. Before this was configurable, the only way to change it was to pass +``timeout=`` at every construction site, which application code cannot do for +the client the ADK builds internally. +""" + +from __future__ import annotations + +import httpx +import pytest + +from agentex.lib.adk.utils._modules.client import ( + _timeout_from_env, + create_async_agentex_client, +) + + +def test_defaults_match_the_sdk_default_timeout(): + """An unconfigured process must behave exactly as it did before.""" + timeout = _timeout_from_env() + assert timeout.connect == 5.0 + assert timeout.read == 300.0 + assert timeout.write == 300.0 + assert timeout.pool == 300.0 + + +def test_connect_timeout_is_configurable(monkeypatch): + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30") + timeout = _timeout_from_env() + assert timeout.connect == 30.0 + # the others are untouched + assert timeout.read == 300.0 + + +def test_all_four_are_configurable(monkeypatch): + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30") + monkeypatch.setenv("AGENTEX_CLIENT_READ_TIMEOUT_SECONDS", "120") + monkeypatch.setenv("AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS", "90") + monkeypatch.setenv("AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS", "60") + timeout = _timeout_from_env() + assert (timeout.connect, timeout.read, timeout.write, timeout.pool) == ( + 30.0, + 120.0, + 90.0, + 60.0, + ) + + +def test_an_empty_value_falls_back_to_the_default(): + """An unset variable and one set to the empty string mean the same thing.""" + with pytest.MonkeyPatch.context() as mp: + mp.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "") + assert _timeout_from_env().connect == 5.0 + + +def test_client_picks_up_the_env_timeout(monkeypatch): + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30") + client = create_async_agentex_client(api_key="test", base_url="http://localhost:5003") + # client.timeout is float | Timeout | None; narrow before reading a component. + assert isinstance(client.timeout, httpx.Timeout) + assert client.timeout.connect == 30.0 + + +def test_explicit_timeout_wins_over_the_environment(monkeypatch): + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30") + client = create_async_agentex_client( + api_key="test", + base_url="http://localhost:5003", + timeout=httpx.Timeout(connect=7.0, read=8.0, write=9.0, pool=10.0), + ) + assert isinstance(client.timeout, httpx.Timeout) + assert client.timeout.connect == 7.0 + + +def test_env_auth_is_still_attached(): + """The factory's original job must survive the change.""" + client = create_async_agentex_client(api_key="test", base_url="http://localhost:5003") + assert client._client.auth is not None + + +def test_a_bad_value_names_the_variable(monkeypatch): + """A malformed value is a configuration error, so it must not be swallowed.""" + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "not-a-number") + with pytest.raises(ValueError, match="AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS"): + _timeout_from_env() + + +def test_the_timeout_does_not_depend_on_the_shared_environment_model(monkeypatch): + """Regression: these must not become EnvironmentVariables fields. + + That model has required fields, is loaded by worker startup and by + EnvAuth.auth_flow on every request, and agentex.lib.adk.utils builds a + client at import time. Routing timeouts through it makes all three depend + on a fully configured environment. + """ + monkeypatch.delenv("AGENT_NAME", raising=False) + monkeypatch.delenv("ACP_URL", raising=False) + assert _timeout_from_env().connect == 5.0 diff --git a/tests/lib/utils/test_registration.py b/tests/lib/utils/test_registration.py new file mode 100644 index 000000000..65960d757 --- /dev/null +++ b/tests/lib/utils/test_registration.py @@ -0,0 +1,49 @@ +"""Registration metadata: what an agent reports about itself at startup.""" + +from __future__ import annotations + +import pytest + +from agentex.lib.utils.registration import build_registration_metadata +from agentex.lib.environment_variables import EnvironmentVariables + +SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + + +def _env(**overrides) -> EnvironmentVariables: + return EnvironmentVariables(AGENT_NAME="sample-agent", ACP_URL="http://agent", **overrides) + + +def test_nothing_known_yields_empty_metadata(): + assert build_registration_metadata(_env()) == {} + + +def test_commit_and_repo_reported_when_set(): + env = _env(AGENT_COMMIT_SHA=SHA, AGENT_SOURCE_REPO="git@github.com:scaleapi/Demo.git") + assert build_registration_metadata(env) == { + "commit_sha": SHA, + "source_repo": "github.com/scaleapi/Demo", + } + + +@pytest.mark.parametrize("value", ["latest", "v1.2.3", "rocket_mock_agent-" + SHA, "abc", " "]) +def test_non_commit_values_are_omitted_not_forwarded(value): + """A field named for a commit never holds an image tag, same rule as __commit_sha__.""" + assert "commit_sha" not in build_registration_metadata(_env(AGENT_COMMIT_SHA=value)) + + +def test_repo_normalization_strips_scheme_and_credentials(): + env = _env(AGENT_SOURCE_REPO="https://x-token:secret@GitHub.com/scaleapi/Demo.git") + assert build_registration_metadata(env)["source_repo"] == "github.com/scaleapi/Demo" + + +def test_deployment_id_and_agent_card_still_reported(): + class Card: + def model_dump(self): + return {"name": "sample"} + + env = _env(AGENTEX_DEPLOYMENT_ID="dep-1") + assert build_registration_metadata(env, Card()) == { + "deployment_id": "dep-1", + "agent_card": {"name": "sample"}, + }