diff --git a/.github/workflows/agentex-tutorials-test.yml b/.github/workflows/agentex-tutorials-test.yml index 41b495d71..51f8a2141 100644 --- a/.github/workflows/agentex-tutorials-test.yml +++ b/.github/workflows/agentex-tutorials-test.yml @@ -9,6 +9,10 @@ on: jobs: find-tutorials: + # Repo guard: this workflow is specific to the production repo. Staging carries the + # same file (the trunks are kept SHA-identical) but has none of its secrets, so + # without this it runs and fails red on every codegen push. + if: github.repository == 'scaleapi/scale-agentex-python' runs-on: ubuntu-latest outputs: tutorials: ${{ steps.get-tutorials.outputs.tutorials }} @@ -235,7 +239,7 @@ jobs: retention-days: 1 test-summary: - if: always() + if: always() && github.repository == 'scaleapi/scale-agentex-python' needs: [find-tutorials, test-tutorial] runs-on: ubuntu-latest name: Test Summary diff --git a/.github/workflows/bandit-ci.yml b/.github/workflows/bandit-ci.yml new file mode 100644 index 000000000..d4690a71e --- /dev/null +++ b/.github/workflows/bandit-ci.yml @@ -0,0 +1,69 @@ +name: Bandit + +on: + # Scan changed files in PRs: + pull_request: {} + +jobs: + bandit-scan: + name: Bandit + runs-on: ubuntu-22.04 + if: (github.actor != 'dependabot[bot]') && (github.actor != 'github-actions[bot]') + steps: + - name: Install PyCQA/bandit + shell: bash + run: | + pip install bandit + - name: Checkout base branch + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + submodules: false + - name: Run a baseline scan + shell: bash + run: | + bandit --recursive --aggregate file . -f json -o baseline.json || true + - name: Checkout feature branch + shell: bash + run: | + git fetch origin $GITHUB_HEAD_REF + git checkout $GITHUB_HEAD_REF + - name: Run Scan off of baseline + shell: bash + run: | + bandit --recursive --aggregate file . --baseline baseline.json -f json -o results.json || true + - name: Install logging prerequisites + shell: bash {0} + run: | + sudo apt-get -y install jq curl + - name: Generate logger template + shell: bash {0} # don't fail the job if the logging fails + run: | + jq -n --arg organization $GITHUB_REPOSITORY_OWNER \ + -n --arg time $( date +'%Y-%m-%dT%H:%M:%SZ' ) \ + -n --arg action $GITHUB_WORKFLOW \ + -n --arg repository $GITHUB_REPOSITORY \ + -n --arg sha $GITHUB_SHA \ + -n --arg branch $GITHUB_HEAD_REF \ + -n --arg link "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + -f .github/workflows/output-template.json > tmp-output.json + - name: Format results appropriately from results.json + shell: bash {0} # don't fail the job if the logging fails + run: | + jq '.results | map({"path": .filename, "message": .issue_text, "line": .line_number})' results.json > tmp.json + # --slurpfile, not --argjson "$( output.json + - name: Send unified results to logging cluster + shell: bash {0} # don't fail the job if the logging fails + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.N8N_PRODSEC_ACTIONS_TOKEN }}" \ + -d @./output.json \ + ${{ secrets.N8N_PRODSEC_ACTIONS_ENDPOINT }} diff --git a/.github/workflows/build-and-push-tutorial-agent.yml b/.github/workflows/build-and-push-tutorial-agent.yml index b35154389..33c691d8b 100644 --- a/.github/workflows/build-and-push-tutorial-agent.yml +++ b/.github/workflows/build-and-push-tutorial-agent.yml @@ -25,6 +25,10 @@ permissions: jobs: check-permissions: + # Repo guard: this workflow is specific to the production repo. Staging carries the + # same file (the trunks are kept SHA-identical) but has none of its secrets, so + # without this it runs and fails red on every codegen push. + if: github.repository == 'scaleapi/scale-agentex-python' runs-on: ubuntu-latest steps: - name: Check event type and permissions diff --git a/.github/workflows/harness-integration.yml b/.github/workflows/harness-integration.yml index ab20929a8..819006a50 100644 --- a/.github/workflows/harness-integration.yml +++ b/.github/workflows/harness-integration.yml @@ -12,6 +12,10 @@ on: jobs: conformance: + # Repo guard: this workflow is specific to the production repo. Staging carries the + # same file (the trunks are kept SHA-identical) but has none of its secrets, so + # without this it runs and fails red on every codegen push. + if: github.repository == 'scaleapi/scale-agentex-python' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -38,6 +42,10 @@ jobs: # trigger above uses a `test_harness_*.py` glob so new suites are picked up # automatically. live-matrix: + # Repo guard: this workflow is specific to the production repo. Staging carries the + # same file (the trunks are kept SHA-identical) but has none of its secrets, so + # without this it runs and fails red on every codegen push. + if: github.repository == 'scaleapi/scale-agentex-python' runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index dc165a271..49a894981 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -24,8 +24,17 @@ jobs: # These bots may not always emit Conventional-Commits-formatted titles # (dependabot's default "Bump foo from 1.0 to 1.1" doesn't match) and we # don't want their PRs blocked by this check. Mirrors validate-pr-base. + # + # agentex-sdk-sync[bot] is this repo's own SDK automation. release-please + # runs here as a CLI under that App rather than as the release-please[bot] + # GitHub App, so its release pull requests are authored by + # agentex-sdk-sync[bot] and the entry above never matched them. Their + # titles come from release-please's configured pull-request-title-pattern, + # "release: ", which is not a Conventional Commits type and cannot + # be changed without also changing the string release-please parses back + # when it cuts the release. The same App opens the promote pull requests. case "$PR_AUTHOR" in - stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]) + stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]|agentex-sdk-sync\[bot\]) echo "PR is from automation ($PR_AUTHOR); skipping title check." exit 0 ;; @@ -93,7 +102,7 @@ jobs: # Exempt automated PRs (must mirror validate-pr-title's list). case "$PR_AUTHOR" in - stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]) + stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]|agentex-sdk-sync\[bot\]) delete_comment echo "PR is from automation ($PR_AUTHOR); allowing PR targeting main." exit 0 diff --git a/.github/workflows/opengrep-ci.yml b/.github/workflows/opengrep-ci.yml new file mode 100644 index 000000000..92cb7da2a --- /dev/null +++ b/.github/workflows/opengrep-ci.yml @@ -0,0 +1,18 @@ +name: OpenGrep + +on: + pull_request: {} + +concurrency: + group: opengrep-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + id-token: write + +jobs: + opengrep: + uses: scaleapi/required-actions/.github/workflows/opengrep-ci.yml@opengrep-4core-runner + secrets: inherit diff --git a/.github/workflows/opengrep-fp.yml b/.github/workflows/opengrep-fp.yml new file mode 100644 index 000000000..5693b4421 --- /dev/null +++ b/.github/workflows/opengrep-fp.yml @@ -0,0 +1,18 @@ +name: OpenGrep FP Triage + +on: + pull_request_review_comment: + types: [created] + +permissions: + pull-requests: write + id-token: write + +jobs: + triage: + if: | + (startsWith(github.event.comment.body, '/fp') || + startsWith(github.event.comment.body, '/FP')) && + !endsWith(github.actor, '[bot]') + uses: scaleapi/required-actions/.github/workflows/opengrep-fp.yml@main + secrets: inherit diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b5ff5ca9b..23c5f58fd 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -20,6 +20,10 @@ on: jobs: publish: + # Repo guard: this workflow is specific to the production repo. Staging carries the + # same file (the trunks are kept SHA-identical) but has none of its secrets, so + # without this it runs and fails red on every codegen push. + if: github.repository == 'scaleapi/scale-agentex-python' name: publish runs-on: ubuntu-latest diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 000000000..e9ddc6392 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,80 @@ +name: Release Please + +# Hand-edited from the stlc-generated template. `.github/workflows/*.yml` is +# scaffold-once, so this survives every later build -- upstream's own source cites +# exactly this PAT-to-App swap as the reason that preservation exists. Do NOT run +# `stlc build --rewrite-scaffold` without reapplying these three changes. +# +# What changed from the generated file, and why each is load-bearing: +# +# 1. App token instead of `secrets.RELEASE_PLEASE_TOKEN`, which does not exist +# and which we do not want to create -- eliminating PATs was the point of the +# App migration. It is deliberately NOT `GITHUB_TOKEN`: releases created by +# GITHUB_TOKEN do not trigger other workflows, so publish-*.yml would never +# fire and the release would stop one hop short of the registry. +# +# 2. The `npx release-please@16` CLI instead of googleapis/release-please-action. +# scale-agentex-typescript sets `allowed_actions: selected` and does not permit +# that action; the CLI needs only actions/-owned steps, which +# `github_owned_allowed: true` covers on both production repos. +# +# 3. `issues: write` on the minted token. release-please drives its +# autorelease:pending -> autorelease:tagged labels through the Issues API. +# Without it you get duplicate release pull requests. The generated file omits +# it, and the omission is silent until it bites. +# +# Requires AGENTEX_SDK_SYNC_PRIVATE_KEY (secret) and AGENTEX_SDK_SYNC_APP_ID +# (variable) on the PRODUCTION repo -- a workflow only reads secrets from the repo +# it runs in, and the guard below means that is production. +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + release-please: + # Self-routing: this file is SHA-identical on the staging trunk, where it must + # stay inert. Only production cuts releases. + if: github.repository == 'scaleapi/scale-agentex-python' + runs-on: ubuntu-latest + steps: + - name: Mint release token + id: release-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.AGENTEX_SDK_SYNC_APP_ID }} + private-key: ${{ secrets.AGENTEX_SDK_SYNC_PRIVATE_KEY }} + owner: scaleapi + repositories: scale-agentex-python + permission-contents: write + permission-pull-requests: write + permission-issues: write + permission-metadata: read + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Release PR + GitHub release + env: + RP_TOKEN: ${{ steps.release-token.outputs.token }} + run: | + # release-pr opens or updates the version-bump pull request; + # github-release turns an already-merged one into the tag + GitHub Release + # that publish-pypi.yml / publish-npm.yml trigger on. Both are idempotent, + # so running the pair on every push carries a release the whole way. + # + # No checkout step is needed: release-please reads the config and manifest + # from the repo over the API. + npx --yes release-please@16 release-pr \ + --token="$RP_TOKEN" --repo-url="${{ github.repository }}" \ + --config-file=release-please-config.json \ + --manifest-file=.release-please-manifest.json + npx --yes release-please@16 github-release \ + --token="$RP_TOKEN" --repo-url="${{ github.repository }}" \ + --config-file=release-please-config.json \ + --manifest-file=.release-please-manifest.json diff --git a/.github/workflows/trufflehog-bypass.yml b/.github/workflows/trufflehog-bypass.yml new file mode 100644 index 000000000..51bdc8d3a --- /dev/null +++ b/.github/workflows/trufflehog-bypass.yml @@ -0,0 +1,16 @@ +name: TruffleHog Bypass Handler + +on: + issue_comment: + types: [created] + +jobs: + bypass: + if: | + github.event.issue.pull_request && + contains(github.event.comment.body, '/trufflehog-bypass') + uses: scaleapi/required-actions/.github/workflows/trufflehog-bypass-handler.yml@main + permissions: + pull-requests: write + contents: read + actions: write diff --git a/.github/workflows/trufflehog-ci.yml b/.github/workflows/trufflehog-ci.yml new file mode 100644 index 000000000..39d4a30b8 --- /dev/null +++ b/.github/workflows/trufflehog-ci.yml @@ -0,0 +1,13 @@ +name: TruffleHog Secret Scan + +on: + pull_request: + branches: [master, main] + +jobs: + scan: + uses: scaleapi/required-actions/.github/workflows/trufflehog-scan.yml@main + permissions: + contents: read + pull-requests: write + id-token: write diff --git a/.github/workflows/trufflehog-weekly.yml b/.github/workflows/trufflehog-weekly.yml new file mode 100644 index 000000000..f2efa6007 --- /dev/null +++ b/.github/workflows/trufflehog-weekly.yml @@ -0,0 +1,26 @@ +name: TruffleHog Weekly Scan + +on: + schedule: + - cron: '0 3 * * 0' + workflow_dispatch: + inputs: + since_commit: + description: 'Override: Scan from this commit SHA (leave empty to use stored value)' + required: false + type: string + full_scan: + description: 'Run full history scan (ignores since_commit)' + required: false + type: boolean + default: false + +jobs: + scan: + uses: scaleapi/required-actions/.github/workflows/trufflehog-weekly-scan.yml@main + with: + since_commit: ${{ inputs.since_commit || '' }} + full_scan: ${{ inputs.full_scan || false }} + permissions: + contents: read + id-token: write diff --git a/.stats.yml b/.stats.yml index a15b6b97b..217405cdc 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1 @@ configured_endpoints: 75 -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/adk/README.md b/adk/README.md index ef7c553d9..206ba993b 100644 --- a/adk/README.md +++ b/adk/README.md @@ -27,26 +27,6 @@ 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/adk/pyproject.toml b/adk/pyproject.toml index 2ca3dc407..f251f3afc 100644 --- a/adk/pyproject.toml +++ b/adk/pyproject.toml @@ -65,7 +65,6 @@ dependencies = [ # agentex/lib/* uses `from typing import override` (3.12+) in 19 files. # The slim agentex-client keeps 3.11 support. requires-python = ">= 3.12,<4" - classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", @@ -77,23 +76,6 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", ] -# No `obs` extra, deliberately — do not add one for sgp-obs. -# -# sgp-obs is not on public PyPI (it is served from Scale's curated CodeArtifact -# mirror), and declaring it in [project.optional-dependencies] makes THIS repo's uv -# workspace unresolvable: `uv sync` re-locks, locking must resolve every declared -# optional dependency of every workspace member, and there is no way to exempt one. -# Measured: `uv lock --check`, `uv sync --all-extras`, plain `uv sync` with no extras, -# and `uv sync --all-extras --no-extra obs` all fail (`--no-extra` filters what is -# installed, not what is resolved); `uv lock` has no `--no-extra`; and -# `[tool.uv] override-dependencies` does not exempt it either. Only `--frozen` works, -# which would leave nobody able to re-lock this repo again. -# -# So the dependency is the AGENT's to declare — `sgp-obs[genai-auto,http,otlp]` -# against the mirror — and the SDK wires it when it is importable. See -# agentex/lib/core/observability/sgp_obs_setup.py; nothing imports sgp_obs outside a -# try, so a plain `pip install agentex-sdk` is unaffected either way. - [project.urls] Homepage = "https://github.com/scaleapi/scale-agentex-python" Repository = "https://github.com/scaleapi/scale-agentex-python" diff --git a/release-please-config.json b/release-please-config.json index 7bae5f5a3..88c752298 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -20,7 +20,7 @@ ] } ], - "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "include-v-in-tag": true, "include-component-in-tag": true, "versioning": "prerelease", diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index e766fabe6..bd14f19fb 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -20,7 +20,7 @@ UPLOAD_RESPONSE=$(curl -v -X PUT \ if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/agentex-sdk-python/$SHA/$FILENAME'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/scale-agentex-python-staging/$SHA/$FILENAME'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 diff --git a/src/agentex/_client.py b/src/agentex/_client.py index b52ae6b78..b67494a95 100644 --- a/src/agentex/_client.py +++ b/src/agentex/_client.py @@ -71,7 +71,7 @@ ] ENVIRONMENTS: Dict[str, str] = { - "production": "http://localhost:5003", + "production": "https://agentex.sgp.scale.com", "development": "http://localhost:5003", } diff --git a/src/agentex/lib/adk/utils/_modules/client.py b/src/agentex/lib/adk/utils/_modules/client.py index 5312b7b6a..725289631 100644 --- a/src/agentex/lib/adk/utils/_modules/client.py +++ b/src/agentex/lib/adk/utils/_modules/client.py @@ -1,4 +1,3 @@ -import os from typing import override import httpx @@ -27,50 +26,7 @@ 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/debug/debug_handlers.py b/src/agentex/lib/cli/debug/debug_handlers.py index a27d682cd..98746387f 100644 --- a/src/agentex/lib/cli/debug/debug_handlers.py +++ b/src/agentex/lib/cli/debug/debug_handlers.py @@ -16,7 +16,6 @@ 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 @@ -67,7 +66,6 @@ async def start_temporal_worker_debug( env=debug_env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, - limit=SUBPROCESS_STREAM_LIMIT, ) @@ -121,7 +119,6 @@ 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/deploy_handlers.py b/src/agentex/lib/cli/handlers/deploy_handlers.py index e1cd1965c..605d91709 100644 --- a/src/agentex/lib/cli/handlers/deploy_handlers.py +++ b/src/agentex/lib/cli/handlers/deploy_handlers.py @@ -389,8 +389,6 @@ 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: @@ -432,14 +430,6 @@ 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/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py index 18ee84e93..3a43e95dd 100644 --- a/src/agentex/lib/cli/handlers/run_handlers.py +++ b/src/agentex/lib/cli/handlers/run_handlers.py @@ -12,7 +12,6 @@ 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, @@ -24,11 +23,6 @@ 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""" @@ -221,7 +215,6 @@ async def start_acp_server( env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, - limit=SUBPROCESS_STREAM_LIMIT, ) @@ -241,68 +234,23 @@ 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. - - 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. - """ + """Stream process output with prefix""" try: if process.stdout is None: return - consecutive_read_errors = 0 while True: - 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 - + line = await process.stdout.readline() if not line: break - - try: - decoded_line = line.decode("utf-8").rstrip() - except UnicodeDecodeError as e: - logger.warning(f"Dropped an undecodable log line from {prefix} ({e}).") - continue - + decoded_line = line.decode("utf-8").rstrip() if decoded_line: # Only print non-empty lines console.print(f"[dim]{prefix}:[/dim] {decoded_line}") except Exception as 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." - ) + logger.debug(f"Output streaming ended for {prefix}: {e}") async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None): diff --git a/src/agentex/lib/cli/templates/PRIVATE_INDEX.md b/src/agentex/lib/cli/templates/PRIVATE_INDEX.md deleted file mode 100644 index 932bd9819..000000000 --- a/src/agentex/lib/cli/templates/PRIVATE_INDEX.md +++ /dev/null @@ -1,74 +0,0 @@ -# The private package index in scaffold Dockerfiles - -Every scaffold Dockerfile mounts a build secret named `codeartifact-pip-conf`. It lets an agent -install Scale-internal packages — `sgp-obs`, for instance — that are not on public PyPI, without the -build holding any registry credential of its own. The control-plane broker mints a short-lived -CodeArtifact token per build and injects it as that secret. - -- Design: [Private Package Access for Customer Agents (PRD)](https://app.notion.com/p/Private-Package-Access-for-Customer-Agents-PRD-3ad904d6e6cb802cb091df1c25e230bc) -- Tracking: [SGPINF-1568](https://linear.app/scale-epd/issue/SGPINF-1568/provide-scale-internal-packages-to-agentex-agents-in-customer) - -## It is inert by default - -The mount is `required=false` and guarded by `[ -s ... ]`, so with no secret injected the build is -byte-identical to one without any of this. That covers every local build, every CI build, and every -agent that never opts in. An empty secret file is skipped too. - -## Opting in - -Add the index to the agent's `pyproject.toml`: - -```toml -[[tool.uv.index]] -name = "scale-pypi" -url = "" -``` - -**No `default = true`, deliberately.** An earlier revision of this snippet had it, which was -misleading in both directions. It would not survive the build — the Dockerfiles export -`UV_INDEX`, which binds the mirror as a *named* index ahead of public PyPI rather than -replacing it as the default, and a name rebound that way does not carry the project entry's -default flag. And it is not the behaviour we want anyway: the mirror exists to supply the -Scale-internal packages that are not on public PyPI, not to become the sole source for every -dependency. - -So resolution is **mirror first, public PyPI as fallback**. `sgp-obs` can only come from the -mirror, because it exists nowhere else. An ordinary dependency the mirror happens not to carry -still resolves from PyPI instead of failing the build, which is what keeps a scaffolded agent -building when the mirror is incomplete or unreachable. - -The name must be exactly `scale-pypi`. uv applies `UV_INDEX_SCALE_PYPI_USERNAME` / -`UV_INDEX_SCALE_PYPI_PASSWORD` to the index of that name, so renaming it makes the credentials -silently stop applying. Setting `UV_INDEX_URL` instead does not authenticate a *named* index at -all, and the resolve fails with a 401. - -## Three things that are easy to get wrong - -**The token arrives percent-encoded.** The buildspec URL-encodes it to embed it in the pip config's -URL userinfo, so a token containing `+`, `/` or `=` arrives as `%2B`, `%2F`, `%3D`. The `uv sync` -templates decode it before exporting it as a password. Passing it through still-encoded sends a -different string and the resolve 401s. - -**The credential must not follow project-controlled configuration.** uv binds credentials by index -*name*, and the name-to-URL mapping would otherwise come from the agent's own `pyproject.toml` — so a -project that pointed `scale-pypi` at another host would receive the token. Verified against a local -server: the rogue host receives `Authorization: Basic aws:` and the real index is never -contacted. The templates therefore export `UV_INDEX` to re-bind the name to the URL the *broker* -supplied, which overrides whatever the project declared. With that in place the rogue host is never -contacted. The pinned URL carries no userinfo; the token still travels only in -`UV_INDEX_SCALE_PYPI_PASSWORD`. - -The case this defends is not a malicious agent author — they also write the Dockerfile and could read -the mounted secret directly. It is a *contributed* change to a project file, where a one-line URL edit -is far less conspicuous in review than an exfiltration command in a Dockerfile. - -**The two template variants work differently, deliberately.** - -| Template | Install step | How the credential is supplied | -| --- | --- | --- | -| `Dockerfile-uv.j2` | `uv sync` against the agent's `pyproject.toml` | Named index `scale-pypi`, pinned via `UV_INDEX`, token decoded into `UV_INDEX_SCALE_PYPI_PASSWORD` | -| `Dockerfile.j2` | `uv pip install -r requirements.txt` | No pyproject is present, so there is no named index to bind to. The credentialed URL is used directly via `UV_DEFAULT_INDEX` | - -The `requirements.txt` variant does **not** decode the token, and that is the point: it stays inside -the URL, already encoded for exactly that use. Decoding it there would corrupt it. It is also not -exposed to the redirection problem above, because the URL comes wholly from the injected secret. diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 index 8a22d0f89..93d0f82d1 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 @@ -34,20 +34,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -55,13 +42,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 index 3556f6dfd..d714d96f9 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 @@ -33,19 +33,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 index b3c03c988..02860b9b9 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 @@ -34,20 +34,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -55,13 +42,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 index c0b3fc385..1a8eb1484 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 @@ -33,19 +33,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 index 7f148e274..0395caf74 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 index 0a416aa38..056d60b96 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 index ad8b6e41d..66ee31243 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 @@ -20,7 +20,7 @@ from dotenv import load_dotenv load_dotenv() -from agents import Agent, Runner, function_tool, set_trace_processors +from agents import Agent, Runner, function_tool, set_tracing_disabled from agentex.lib import adk from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams @@ -34,17 +34,10 @@ from agentex.lib.core.harness.emitter import UnifiedEmitter from agentex.lib.adk import OpenAITurn from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config -# Drop the openai-agents SDK's own exporter, so it can't ship traces to -# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would 401). -# -# Clearing the processor list rather than disabling tracing outright: disabling stops -# spans being produced AT ALL, which silently starves any processor added later — -# including the sgp-obs bridge the SDK installs when observability is on, so a Runner -# turn would contribute no model spans. Clearing instead removes the OpenAI exporter -# (which otherwise stays registered and is merely never fed) while leaving the -# machinery alive for the bridge to attach to. -# Agentex/SGP tracing still runs via the tracing manager. -set_trace_processors([]) +# Disable the openai-agents SDK's native tracer so it doesn't ship traces to +# api.openai.com using OPENAI_API_KEY (which may be a LiteLLM proxy key). +# SGP tracing below still runs via the Agentex tracing manager. +set_tracing_disabled(True) logger = make_logger(__name__) diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 index 7f148e274..0395caf74 100644 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/default/Dockerfile.j2 b/src/agentex/lib/cli/templates/default/Dockerfile.j2 index 7f148e274..0395caf74 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 index 8a22d0f89..93d0f82d1 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 @@ -34,20 +34,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -55,13 +42,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 index cd0338d18..6cdc70799 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 @@ -33,19 +33,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 index b3c03c988..02860b9b9 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 @@ -34,20 +34,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -55,13 +42,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 index 79293756d..afa4470d9 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 @@ -33,19 +33,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 index d0c204e47..4d9f41d45 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 index d0c204e47..4d9f41d45 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 index 315c5a6ae..07546bffb 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 @@ -15,7 +15,7 @@ from __future__ import annotations from datetime import datetime -from agents import Runner, set_trace_processors +from agents import Runner, set_tracing_disabled from agents.sandbox import SandboxAgent, SandboxRunConfig from agents.run_config import RunConfig from agents.sandbox.sandboxes.unix_local import ( @@ -25,17 +25,10 @@ from agents.sandbox.sandboxes.unix_local import ( from project.tools import get_capabilities -# Drop the openai-agents SDK's own exporter, so it can't ship traces to -# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would 401). -# -# Clearing the processor list rather than disabling tracing outright: disabling stops -# spans being produced AT ALL, which silently starves any processor added later — -# including the sgp-obs bridge the SDK installs when observability is on, so a Runner -# turn would contribute no model spans. Clearing instead removes the OpenAI exporter -# (which otherwise stays registered and is merely never fed) while leaving the -# machinery alive for the bridge to attach to. -# Agentex/SGP tracing still runs via the tracing manager. -set_trace_processors([]) +# Disable the openai-agents SDK's native tracer so it doesn't ship traces to +# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would +# 401). Agentex tracing still runs via the tracing manager configured in acp.py. +set_tracing_disabled(True) MODEL_NAME = "gpt-4o-mini" INSTRUCTIONS = """You are a local sandbox assistant. diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 index d0c204e47..4d9f41d45 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 index 07849e81d..41029f2ce 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 @@ -13,19 +13,12 @@ from agentex.types.task_message_update import TaskMessageUpdate, StreamTaskMessa from agentex.types.task_message_content import TaskMessageContent from agentex.types.text_content import TextContent from agentex.lib.utils.logging import make_logger -from agents import Agent, Runner, RunConfig, function_tool, set_trace_processors - -# Drop the openai-agents SDK's own exporter, so it can't ship traces to -# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would 401). -# -# Clearing the processor list rather than disabling tracing outright: disabling stops -# spans being produced AT ALL, which silently starves any processor added later — -# including the sgp-obs bridge the SDK installs when observability is on, so a Runner -# turn would contribute no model spans. Clearing instead removes the OpenAI exporter -# (which otherwise stays registered and is merely never fed) while leaving the -# machinery alive for the bridge to attach to. -# Agentex/SGP tracing still runs via the tracing manager. -set_trace_processors([]) +from agents import Agent, Runner, RunConfig, function_tool, set_tracing_disabled + +# Disable the openai-agents SDK's native tracer so it doesn't ship traces to +# api.openai.com using OPENAI_API_KEY (which may be a LiteLLM proxy key). +# SGP tracing below still runs via the Agentex tracing manager. +set_tracing_disabled(True) logger = make_logger(__name__) diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 index d0c204e47..4d9f41d45 100644 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 index 9b4f8d25b..dd3035f7b 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 @@ -30,20 +30,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -51,13 +38,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 index d0c204e47..4d9f41d45 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 @@ -29,19 +29,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 index 1665bceb1..f8746c573 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 @@ -42,20 +42,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -63,13 +50,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 index 1297b7bd7..225863607 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 @@ -41,19 +41,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project 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 108316ab9..8191ad80f 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.core.temporal.logging import make_workflow_logger +from agentex.lib.utils.logging import make_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_workflow_logger(__name__) +logger = make_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 index 41d83e31c..7e31387fa 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 @@ -42,20 +42,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -63,13 +50,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 index d77d8073f..0ae4e2079 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 @@ -41,19 +41,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project 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 9890efab8..1004ebfb8 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.core.temporal.logging import make_workflow_logger +from agentex.lib.utils.logging import make_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_workflow_logger(__name__) +logger = make_logger(__name__) MODEL = os.environ.get("CODEX_MODEL", "o4-mini") diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 index 56b4d949c..6746869df 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 @@ -36,20 +36,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -57,13 +44,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 index 5bb133a22..ba47485a9 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 @@ -35,19 +35,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project 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 d0db42bc8..14bafabc1 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.core.temporal.logging import make_workflow_logger +from agentex.lib.utils.logging import make_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_workflow_logger(__name__) +logger = make_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 index a674d7d35..0d9801016 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 @@ -36,20 +36,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -57,13 +44,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 index a9a63757d..4c1798c42 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 @@ -35,19 +35,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project 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 6897cd5a8..af8b7a299 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,22 +7,15 @@ 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.core.temporal.logging import make_workflow_logger +from agentex.lib.utils.logging import make_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables -from agents import Agent, Runner, set_trace_processors - -# Drop the openai-agents SDK's own exporter, so it can't ship traces to -# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would 401). -# -# Clearing the processor list rather than disabling tracing outright: disabling stops -# spans being produced AT ALL, which silently starves any processor added later — -# including the sgp-obs bridge the SDK installs when observability is on, so a Runner -# turn would contribute no model spans. Clearing instead removes the OpenAI exporter -# (which otherwise stays registered and is merely never fed) while leaving the -# machinery alive for the bridge to attach to. -# Agentex/SGP tracing still runs via the tracing manager. -set_trace_processors([]) +from agents import Agent, Runner, set_tracing_disabled + +# Disable the openai-agents SDK's native tracer so it doesn't ship traces to +# api.openai.com using OPENAI_API_KEY (which may be a LiteLLM proxy key). +# SGP tracing below still runs via the Agentex tracing manager. +set_tracing_disabled(True) from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import TemporalStreamingHooks from pydantic import BaseModel @@ -44,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_workflow_logger(__name__) +logger = make_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/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 index a674d7d35..0d9801016 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 @@ -36,20 +36,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -57,13 +44,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 index a9a63757d..4c1798c42 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 @@ -35,19 +35,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project 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 0f25e961c..6dcca3002 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.core.temporal.logging import make_workflow_logger +from agentex.lib.utils.logging import make_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_workflow_logger(__name__) +logger = make_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 index a674d7d35..0d9801016 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 @@ -36,20 +36,7 @@ WORKDIR /app/{{ project_path_from_build_root }} COPY {{ project_path_from_build_root }}/pyproject.toml ./ # Install dependencies (without project itself, for layer caching) -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local -# builds, CI builds, and agents that never opt in are unaffected. -# -# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting -# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-install-project --no-dev # Copy the project code @@ -57,13 +44,6 @@ COPY {{ project_path_from_build_root }}/project ./project # Install the project RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ - export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ - | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ - fi; \ uv sync --no-dev ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 index a9a63757d..4c1798c42 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 @@ -35,19 +35,8 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr WORKDIR /app/{{ project_path_from_build_root }} -# Optional private index for Scale-internal packages such as sgp-obs, injected by the -# control-plane broker (SGPINF-1568). Inert unless the secret is present. -# -# This variant installs from requirements.txt, so there is no pyproject.toml for uv to -# read a named index out of; the credentialed URL is used directly and is deliberately -# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. -# # Install the required Python packages -RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ - if [ -s /run/secrets/codeartifact-pip-conf ]; then \ - export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ - fi; \ - uv pip install --system -r requirements.txt +RUN uv pip install --system -r requirements.txt # Copy the project code COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project 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 8c23ecfc1..56db5abf3 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.core.temporal.logging import make_workflow_logger +from agentex.lib.utils.logging import make_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_workflow_logger(__name__) +logger = make_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) class {{ workflow_class }}(BaseWorkflow): diff --git a/src/agentex/lib/cli/tests/__init__.py b/src/agentex/lib/cli/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/cli/tests/test_template_tracing.py b/src/agentex/lib/cli/tests/test_template_tracing.py deleted file mode 100644 index adb76fd08..000000000 --- a/src/agentex/lib/cli/tests/test_template_tracing.py +++ /dev/null @@ -1,57 +0,0 @@ -"""The openai-agents scaffolds must not disable tracing outright. - -`set_tracing_disabled(True)` stops openai-agents producing spans AT ALL, which -silently starves any processor registered later — including the sgp-obs bridge the SDK -installs when observability is on. The bridge still reports itself installed, so a -Runner turn contributes no model spans and nothing says why. - -Measured against a spy processor: - - set_tracing_disabled(True) -> processors ['BatchTraceProcessor', 'Spy'], spy saw 0 - set_trace_processors([]) -> processors ['Spy'], spy saw 1 - -Note the first row: disabling tracing leaves the OpenAI exporter REGISTERED, merely -never fed. Clearing the list actually removes it, so the replacement is strictly better -at the thing the original was trying to do — keep traces away from api.openai.com. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -TEMPLATES = Path(__file__).resolve().parents[1] / "templates" - - -def _templates_using_agents_tracing() -> list[Path]: - return sorted( - p for p in TEMPLATES.rglob("*.j2") if "set_trace_processors" in p.read_text() - ) - - -def test_some_templates_were_found(): - """Guards the glob itself: if the templates move, the assertions below would - vacuously pass on an empty list.""" - assert _templates_using_agents_tracing(), f"no templates found under {TEMPLATES}" - - -@pytest.mark.parametrize( - "template", _templates_using_agents_tracing(), ids=lambda p: p.parent.parent.name -) -class TestOpenAIAgentsScaffolds: - def test_does_not_disable_tracing(self, template: Path): - text = template.read_text() - assert "set_tracing_disabled(" not in text, ( - f"{template} disables openai-agents tracing, which starves the sgp-obs bridge" - ) - - def test_clears_the_processor_list_instead(self, template: Path): - assert "set_trace_processors([])" in template.read_text() - - def test_imports_what_it_calls(self, template: Path): - text = template.read_text() - assert "set_trace_processors" in text.split("\n\n")[0] or any( - "import" in line and "set_trace_processors" in line - for line in text.splitlines() - ), f"{template} calls set_trace_processors without importing it" diff --git a/src/agentex/lib/cli/utils/cli_utils.py b/src/agentex/lib/cli/utils/cli_utils.py index 4238e8fd9..43b3fba62 100644 --- a/src/agentex/lib/cli/utils/cli_utils.py +++ b/src/agentex/lib/cli/utils/cli_utils.py @@ -5,18 +5,6 @@ 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/core/adapters/llm/_genai_metrics.py b/src/agentex/lib/core/adapters/llm/_genai_metrics.py deleted file mode 100644 index df16f5b94..000000000 --- a/src/agentex/lib/core/adapters/llm/_genai_metrics.py +++ /dev/null @@ -1,301 +0,0 @@ -"""GenAI metrics for the litellm gateway, via ``sgp_obs.metrics.genai.call()``. - -Why the SDK does this rather than leaving it to zero-code instrumentation: - -Most model calls in the fleet reach the wire through the ``openai`` client, and for -those, patching that one client covers everything with no code — ``Runner.run``, the -ADK's openai provider, and litellm pointed at an OpenAI-compatible proxy. The client -patch cannot help in two situations, and this gateway hits both: - -1. **litellm routing natively** to Anthropic, Bedrock, Vertex or Azure never touches - the ``openai`` client, so nothing records it at all. -2. Even in proxy mode, the patch sits *inside* the OpenAI client, so it reports - ``gen_ai.provider.name="openai"`` — the protocol. It cannot know that the caller - asked for ``claude-sonnet-4``. This gateway chose the vendor, so it can say so. - -``transport=`` resolves the overlap between the two: when the call is going out over -the OpenAI client, we name that, and ``call()`` stands down if the client instrumentor -is already recording. When litellm routes natively there is no such overlap, so we -record. That decision is made per call, from the model string, in -:func:`_split_model`. - -Everything here is fail-open: sgp-obs is an optional dependency and a telemetry problem -must never fail a model call. If the import fails, :func:`inference_call` returns an -object that records nothing, and the failure is remembered so that later calls cost an -identity check rather than another walk of sys.path. -""" - -from __future__ import annotations - -from typing import Any - -from agentex.lib.utils.logging import make_logger - -logger = make_logger(__name__) - -# litellm's directive for "send this to the configured OpenAI-compatible proxy". It is -# a routing instruction, not a vendor, so it is stripped before reading the vendor. -_PROXY_PREFIX = "litellm_proxy/" - -# A bare model name with no "/" prefix is OpenAI, per litellm's own default. -_DEFAULT_VENDOR = "openai" - -# Providers litellm dispatches through the `openai` Python client, and which the OpenAI -# client instrumentor therefore already records, but which litellm does NOT carry in -# `openai_compatible_providers`. Azure is the one that matters: it is served by -# openai.AzureOpenAI (litellm/main.py, `if custom_llm_provider == "azure"`), so reading -# the prefix alone and calling it a native vendor double-counted every Azure call. -_EXTRA_OPENAI_CLIENT_PROVIDERS = frozenset( - {"openai", "azure", "azure_text", "text-completion-openai", "custom_openai"} -) - -_OPENAI_CLIENT_PROVIDERS_UNRESOLVED = object() -_openai_client_providers: Any = _OPENAI_CLIENT_PROVIDERS_UNRESOLVED - - -def _openai_client_provider_set() -> frozenset[str] | None: - """Providers litellm dispatches over the ``openai`` client, or None if unknowable. - - Imported from ``litellm.constants``, which is where the list is defined, rather - than from the ``litellm`` top level, which is an incidental re-export: litellm - declares no ``__all__``, so a type checker treats the top-level name as private - and it carries no stability promise even informally. - """ - global _openai_client_providers - if _openai_client_providers is _OPENAI_CLIENT_PROVIDERS_UNRESOLVED: - try: - from litellm.constants import openai_compatible_providers - - _openai_client_providers = ( - frozenset(openai_compatible_providers) | _EXTRA_OPENAI_CLIENT_PROVIDERS - ) - except Exception: # pragma: no cover - litellm is a hard dependency - logger.warning( - "litellm.constants.openai_compatible_providers is unavailable, so " - "GenAI metrics cannot tell which calls the OpenAI client instrumentor " - "already records. Recording anyway would double-count every " - "openai-compatible provider, so litellm gateway metrics are off for " - "this process." - ) - _openai_client_providers = None - return _openai_client_providers - - -def _over_openai_client(provider: str) -> bool | None: - """Would the OpenAI client instrumentor already have recorded this call? - - None means "cannot tell", which is NOT the same as False and must not collapse - into it: treating an unknown provider as native is what double-counts it. - """ - known = _openai_client_provider_set() - if known is None: - return None - return provider in known - - -_GENAI_UNRESOLVED = object() -_genai_module: Any = _GENAI_UNRESOLVED - - -def _genai() -> Any | None: - """The sgp-obs GenAI metrics module, or None when it is not installed. - - Resolved on first use rather than at import time, so that importing the litellm - adapter does not pay for it and the answer is read after startup has run. - - The debug line is here rather than at the call site because this body runs exactly - once, which is the only place a "said it once" latch is not needed. - """ - global _genai_module - if _genai_module is _GENAI_UNRESOLVED: - try: - # See sgp_obs_setup.py: optional, not publicly installable, absent in CI. - from sgp_obs.metrics import genai # type: ignore[import-not-found] - - _genai_module = genai - except Exception: - _genai_module = None - logger.debug( - "sgp-obs is not available; GenAI metrics are off for litellm calls" - ) - return _genai_module - - -def _split_model(model: str) -> tuple[str, bool | None]: - """``(provider, goes_out_over_the_openai_client)`` for a litellm model string. - - ``"litellm_proxy/anthropic/claude-sonnet-4"`` -> ``("anthropic", True)`` - ``"anthropic/claude-sonnet-4"`` -> ``("anthropic", False)`` - ``"claude-sonnet-4-20250514"`` -> ``("anthropic", False)`` - ``"azure/gpt-4o"`` -> ``("azure", True)`` - ``"gpt-4o"`` -> ``("openai", True)`` - - The provider comes from ``litellm.get_llm_provider`` — the same resolution litellm - uses to route the call — rather than from reading the prefix. Reading the prefix got - two whole classes of call wrong, in opposite directions: - - * **Prefixed but still over the OpenAI client.** ``azure/gpt-4o`` looks like a - native vendor, but litellm serves it with ``openai.AzureOpenAI``, so the client - instrumentor recorded it too and this recorded it a second time. The same held - for every openai-compatible provider litellm supports — groq, deepseek, xai, - fireworks_ai and ~50 others — all of which look "native" to a prefix reader. - * **Unprefixed but NOT OpenAI.** ``claude-sonnet-4-20250514`` is a legal litellm - model string that routes to Anthropic, but a bare name was assumed to be OpenAI, - so this stood down for an instrumentor that never saw the call. Nothing recorded - it and nothing said so. - - The proxy prefix is stripped before resolving, deliberately: ``litellm_proxy/`` is a - routing instruction, so the vendor underneath it is the interesting label — and the - one thing the OpenAI client instrumentor cannot report, since from inside that - client the call is simply "openai". - - A second element of None means the routing table itself could not be read, so - whether this call is already recorded elsewhere is unknown. Callers must stand down - rather than guess; see :func:`inference_call`. - """ - proxied = model.startswith(_PROXY_PREFIX) - rest = model[len(_PROXY_PREFIX):] if proxied else model - - provider = _resolve_provider(rest) - if provider is None: - # litellm could not resolve it, which means it would not route the call either. - # Fall back to the prefix so an exotic string still gets a sensible label. - provider = rest.split("/", 1)[0] if "/" in rest else _DEFAULT_VENDOR - provider = provider or _DEFAULT_VENDOR - - # Proxy mode always leaves over the OpenAI client, whatever the vendor underneath. - return provider, proxied or _over_openai_client(provider) - - -# Resolved providers, keyed by model string. A plain dict rather than lru_cache: -# `functools.lru_cache` is banned in this repo (TID251) and the sanctioned replacement -# lives in `agentex._utils`, which is the generated client half that `agentex/lib` does -# not otherwise import from. This module already keeps two other resolve-once caches, -# so a third is the least surprising option. -# -# Bounded because the key is a model string, and a fine-tune id or a caller building -# names dynamically would otherwise grow it without limit. An agent talks to a handful -# of models, so the cap is never reached in practice; clearing wholesale when it is -# keeps the bookkeeping to nothing. -_PROVIDER_CACHE_MAX = 256 -_provider_cache: dict[str, str | None] = {} - - -def _resolve_provider(model: str) -> str | None: - """litellm's own provider for ``model``, or None when it cannot resolve one. - - ``get_llm_provider`` raises ``BadRequestError`` for a model it does not know - (measured: ``claude-3-5-sonnet-latest`` raises, ``claude-sonnet-4-20250514`` does - not), and a telemetry lookup must never be the reason a model call fails. - - Cached for two reasons beyond speed. litellm prints a red "Provider List: ..." - banner to STDOUT when resolution fails — not through logging, so it cannot be - filtered — and uncached, an agent on a model string litellm cannot place would - print it on every single call. Redirecting stdout around the lookup was the - alternative and is worse: it swaps a process-global for the duration, so under - concurrency it would swallow output belonging to other coroutines. - """ - if not model: - return None - if model in _provider_cache: - return _provider_cache[model] - - provider: str | None = None - try: - from litellm import get_llm_provider - - _model, resolved, _key, _base = get_llm_provider(model=model) - provider = resolved or None - except Exception: - provider = None - - if len(_provider_cache) >= _PROVIDER_CACHE_MAX: - _provider_cache.clear() - _provider_cache[model] = provider - return provider - - -def resolve_model(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: - """The model for a litellm call, whether it arrived by keyword or positionally. - - ``litellm.acompletion`` takes ``model`` as its FIRST positional argument, and the - gateway forwards ``*args`` untouched, so ``gateway.acompletion("anthropic/claude- - sonnet-4", messages)`` is a legal call that puts the model in ``args[0]``. - - Reading only ``kwargs`` there does not merely mislabel the vendor, it loses the - measurement: an empty model resolves to the default vendor "openai", which sets - ``transport=OPENAI``, which makes ``call()`` stand down for the OpenAI client - instrumentor — while litellm routes natively to Anthropic and never touches that - client. Nothing records it and nothing says so. - """ - model = kwargs.get("model") - if not model and args: - model = args[0] - # Positional args are forwarded verbatim, so args[0] is whatever the caller passed; - # only a string can be a litellm model name. - return model if isinstance(model, str) else "" - - -def inference_call(kwargs: dict[str, Any], args: tuple[Any, ...] = ()) -> Any: - """Begin recording one litellm call. Never raises, never returns None.""" - genai = _genai() - if genai is None: - return _NULL_CALL - - try: - model = resolve_model(args, kwargs) - vendor, over_openai_client = _split_model(model) - if over_openai_client is None: - # The routing table could not be read, so we cannot tell whether the - # OpenAI client instrumentor is already recording this call. Recording - # would double-count every openai-compatible provider, and a doubled - # token or cost figure is worse than a missing one: the gap is visible - # and warned about, the doubling is silent and gets believed. - return _NULL_CALL - return genai.call( - provider=vendor, - operation=genai.CHAT, - model=model, - # litellm normalises every vendor's response onto the OpenAI shape, so one - # parser reads them all — which is exactly what `spec` separates from the - # `provider` label. - spec=genai.OPENAI_SPEC, - transport=genai.OPENAI if over_openai_client else "", - ) - except Exception: - logger.debug("could not start a GenAI metrics record", exc_info=True) - return _NULL_CALL - - -class _NullCall: - """What call sites get when sgp-obs is absent. Records nothing, costs nothing.""" - - def observe(self, response: Any) -> Any: - return response - - # Underscored like __aexit__'s params below: present for parity with the real - # sgp-obs call object, never read here. - def failed(self, _error: BaseException) -> None: - return - - async def __aenter__(self) -> "_NullCall": - return self - - async def __aexit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> bool: - return False # never suppress the caller's exception - - -_NULL_CALL = _NullCall() - - -def _reset_for_tests() -> None: - """Forget the resolved module, so a test can present a different sgp-obs. - - The handle is a process-wide latch: without this, the first test to run with - sgp-obs absent would cache None for the rest of the session and every later test - that injects a fake ``sgp_obs.metrics`` would silently exercise the null path. - """ - global _genai_module, _openai_client_providers - _genai_module = _GENAI_UNRESOLVED - _openai_client_providers = _OPENAI_CLIENT_PROVIDERS_UNRESOLVED - _provider_cache.clear() diff --git a/src/agentex/lib/core/adapters/llm/adapter_litellm.py b/src/agentex/lib/core/adapters/llm/adapter_litellm.py index 8fb1602aa..7935f5f49 100644 --- a/src/agentex/lib/core/adapters/llm/adapter_litellm.py +++ b/src/agentex/lib/core/adapters/llm/adapter_litellm.py @@ -6,7 +6,6 @@ from agentex.lib.utils.logging import make_logger from agentex.lib.types.llm_messages import Completion from agentex.lib.core.adapters.llm.port import LLMGateway -from agentex.lib.core.adapters.llm._genai_metrics import inference_call logger = make_logger(__name__) @@ -37,13 +36,9 @@ async def acompletion(self, *args, **kwargs) -> Completion: "Please use self.acompletion_stream instead of self.acompletion to stream responses" ) - # `async with`, not try/except: asyncio.CancelledError is a BaseException, so a - # caller that disappears mid-flight would skip an `except Exception` handler and - # the record would be silently dropped. - async with inference_call(kwargs, args) as call: - # Return a single completion for non-streaming - response = call.observe(await llm.acompletion(*args, **kwargs)) - return Completion.model_validate(response) + # Return a single completion for non-streaming + response = await llm.acompletion(*args, **kwargs) + return Completion.model_validate(response) @override async def acompletion_stream( @@ -52,11 +47,5 @@ async def acompletion_stream( if not kwargs.get("stream"): raise ValueError("To use streaming, please set stream=True in the kwargs") - async with inference_call(kwargs, args) as call: - # observe() takes ownership of the stream and yields the same chunks, so it - # can read time-to-first-chunk and the token totals off the last chunk. - # Wrapping only the `await` would return before the first chunk arrived and - # record zero tokens for every streamed call. - stream = call.observe(await llm.acompletion(*args, **kwargs)) - async for chunk in stream: # type: ignore[misc] - yield Completion.model_validate(chunk) + async for chunk in await llm.acompletion(*args, **kwargs): # type: ignore[misc] + yield Completion.model_validate(chunk) diff --git a/src/agentex/lib/core/adapters/llm/tests/__init__.py b/src/agentex/lib/core/adapters/llm/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py b/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py deleted file mode 100644 index 37f4992f5..000000000 --- a/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Tests for ``agentex.lib.core.adapters.llm._genai_metrics``. - -The important property is the one that holds in every environment today: with -``sgp-obs`` absent, :func:`inference_call` must hand back something the litellm -gateway can drive as an async context manager, whose ``observe()`` returns the -response untouched and which never swallows the caller's exception. That is the -path every agent without the ``obs`` extra takes on every model call, so a -regression here breaks model calls rather than just losing a metric. -""" - -from __future__ import annotations - -import sys -import builtins - -import pytest - -from agentex.lib.core.adapters.llm import _genai_metrics -from agentex.lib.core.adapters.llm._genai_metrics import ( - _split_model, - resolve_model, - inference_call, -) - - -@pytest.fixture(autouse=True) -def _forget_resolved_sgp_obs(): - """Clear the resolved-once module handle around every test. - - It is process-wide state, so without this the first test to run with sgp-obs - absent would cache None for the rest of the session and every later test that - injects a fake ``sgp_obs.metrics`` would silently exercise the null path instead - of the one it means to. - """ - _genai_metrics._reset_for_tests() - yield - _genai_metrics._reset_for_tests() - - -class TestSplitModel: - """``(vendor, goes_out_over_the_openai_client)``. The boolean decides whether - ``call()`` stands down for the OpenAI client instrumentor or records itself, so - getting it wrong either double-counts a call or loses it.""" - - @pytest.mark.parametrize( - ("model", "vendor", "over_openai_client"), - [ - # Proxy mode: litellm sends this to an OpenAI-compatible proxy over the - # openai client, but the caller asked for a non-OpenAI vendor. - ("litellm_proxy/anthropic/claude-sonnet-4", "anthropic", True), - ("litellm_proxy/gpt-4o", "openai", True), - # Native routing: litellm's own handler, no openai client involved. - ("anthropic/claude-sonnet-4", "anthropic", False), - ("bedrock/anthropic.claude-v2", "bedrock", False), - ("vertex_ai/gemini-2.0-flash", "vertex_ai", False), - # A bare name litellm cannot place is OpenAI per its own default, and - # reaches OpenAI through the openai client — the instrumentor sees it. - ("gpt-4o", "openai", True), - ("openai/gpt-4o", "openai", True), - # Azure is served by openai.AzureOpenAI, so the client instrumentor - # records it and we must NOT. Reading the prefix called this native. - ("azure/gpt-4o", "azure", True), - # Every openai-compatible provider litellm supports has the same shape: - # a vendor prefix, but dispatched over the openai client. - ("groq/llama3-8b-8192", "groq", True), - ("deepseek/deepseek-chat", "deepseek", True), - # A bare Anthropic model is legal and routes NATIVELY to Anthropic, so - # nothing else records it. The prefix reader called this OpenAI and - # stood down for an instrumentor that never saw the call. - ("claude-sonnet-4-20250514", "anthropic", False), - # Genuinely native: no openai client anywhere in the path. - ("gemini/gemini-2.0-flash", "gemini", False), - ], - ) - def test_vendor_and_transport(self, model, vendor, over_openai_client): - assert _split_model(model) == (vendor, over_openai_client) - - def test_an_unresolvable_model_falls_back_to_the_prefix(self): - """litellm raises for a model it cannot place (measured: - claude-3-5-sonnet-latest). That call will fail in litellm too, but the lookup - must not raise on the way there.""" - assert _split_model("claude-3-5-sonnet-latest") == ("openai", True) - assert _split_model("madeup_vendor/some-model") == ("madeup_vendor", False) - - def test_empty_model_does_not_raise(self): - """kwargs.get("model") is "" when a caller passes model positionally. - Falling back to litellm's own default is right, and must not blow up.""" - assert _split_model("") == ("openai", True) - - -class TestTheRoutingDecisionComesFromLitellm: - """The boolean decides whether `call()` stands down for the OpenAI client - instrumentor or records itself, so getting it wrong either double-counts a call or - loses it entirely. Both happened while it was read off the model prefix.""" - - def test_azure_is_not_double_counted(self): - """litellm serves azure/* with openai.AzureOpenAI (litellm/main.py, - `if custom_llm_provider == "azure"`), so the client instrumentor already - records it. Recording here as well counted every Azure call twice.""" - _provider, over_openai_client = _split_model("azure/gpt-4o") - assert over_openai_client is True - - def test_a_bare_anthropic_model_is_recorded(self): - """The opposite failure: nothing else sees this call, so standing down meant - it went unmeasured and nothing said so.""" - provider, over_openai_client = _split_model("claude-sonnet-4-20250514") - assert provider == "anthropic" - assert over_openai_client is False - - def test_the_proxy_vendor_survives_resolution(self): - """The reason this module exists at all: from inside the OpenAI client a - proxied call is just "openai". The prefix is stripped before resolving so the - vendor underneath is still the label.""" - assert _split_model("litellm_proxy/anthropic/claude-sonnet-4") == ( - "anthropic", - True, - ) - - def test_the_provider_list_agrees_with_litellm(self): - """Pinned to litellm's own list rather than a copy of it, because the copy - would go stale every release.""" - import litellm.constants - - from agentex.lib.core.adapters.llm._genai_metrics import _over_openai_client - - for provider in list(litellm.constants.openai_compatible_providers)[:20]: - assert _over_openai_client(provider), provider - for provider in ("anthropic", "bedrock", "vertex_ai", "gemini"): - assert not _over_openai_client(provider), provider - - def test_an_unknown_routing_table_stands_down_rather_than_guessing( - self, monkeypatch, caplog - ): - """If the routing table cannot be read we do not know whether the OpenAI client - instrumentor is already recording a call. Recording anyway would double-count - every openai-compatible provider, and a doubled token or cost figure is worse - than a missing one: the gap is visible and warned about, the doubling is silent - and gets believed. So the recorder stands down entirely.""" - import builtins - - real_import = builtins.__import__ - - def no_constants(name, *args, **kwargs): - if name == "litellm.constants": - raise ImportError("litellm.constants is gone") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", no_constants) - _genai_metrics._reset_for_tests() - - with caplog.at_level("WARNING", logger=_genai_metrics.logger.name): - assert _genai_metrics._over_openai_client("groq") is None - assert _genai_metrics._over_openai_client("anthropic") is None - assert _split_model("groq/llama3-8b-8192") == ("groq", None) - - assert any( - "openai_compatible_providers is unavailable" in r.message - for r in caplog.records - ), [r.message for r in caplog.records] - - def test_a_real_sgp_obs_is_not_started_when_routing_is_unknown(self, monkeypatch): - """The property that actually protects the data: no record is started at all, - rather than one started with a guessed transport.""" - import sys - import builtins - - started = [] - - class _Genai: - CHAT = "chat" - OPENAI_SPEC = "openai" - OPENAI = "openai" - - @staticmethod - def call(**kwargs): - started.append(kwargs) - return object() - - module = type(sys)("sgp_obs.metrics") - module.genai = _Genai - monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs")) - monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module) - - real_import = builtins.__import__ - - def no_constants(name, *args, **kwargs): - if name == "litellm.constants": - raise ImportError("litellm.constants is gone") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", no_constants) - _genai_metrics._reset_for_tests() - - assert inference_call({"model": "groq/llama3-8b-8192"}) is _genai_metrics._NULL_CALL - assert started == [], "a record was started with an unknown routing table" - - def test_an_unresolvable_model_does_not_spam_stdout(self): - """litellm prints a red "Provider List" banner to STDOUT (not logging, so it - cannot be filtered) every time resolution fails. Uncached, an agent on a model - string litellm cannot place printed it on every single call.""" - import io - import contextlib - - _genai_metrics._reset_for_tests() - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - for _ in range(25): - _split_model("claude-3-5-sonnet-latest") - assert buf.getvalue().count("Provider List") <= 1, buf.getvalue()[:400] - - -class TestFailsOpenWithoutSgpObs: - @staticmethod - def _hide_sgp_obs(monkeypatch): - for name in [m for m in sys.modules if m.startswith("sgp_obs")]: - monkeypatch.delitem(sys.modules, name, raising=False) - real_import = builtins.__import__ - - def no_sgp_obs(name, *args, **kwargs): - if name == "sgp_obs" or name.startswith("sgp_obs."): - raise ImportError("No module named 'sgp_obs'") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", no_sgp_obs) - # Resolution is cached, so drop anything a previous call resolved -- otherwise - # hiding the module here would have no effect. - _genai_metrics._reset_for_tests() - - def test_returns_a_usable_recorder_not_none(self, monkeypatch): - self._hide_sgp_obs(monkeypatch) - assert inference_call({"model": "gpt-4o"}) is _genai_metrics._NULL_CALL - - async def test_observe_returns_the_response_unchanged(self, monkeypatch): - """The gateway does `call.observe(await acompletion(...))`, so an observe() - that returned None would turn every completion into None.""" - self._hide_sgp_obs(monkeypatch) - sentinel = object() - async with inference_call({"model": "gpt-4o"}) as call: - assert call.observe(sentinel) is sentinel - - async def test_does_not_suppress_the_callers_exception(self, monkeypatch): - """__aexit__ must return falsey. Suppressing here would make a failed model - call look like a successful one that returned nothing.""" - self._hide_sgp_obs(monkeypatch) - with pytest.raises(ValueError, match="upstream"): - async with inference_call({"model": "gpt-4o"}): - raise ValueError("upstream blew up") - - async def test_cancellation_still_propagates(self, monkeypatch): - """CancelledError is a BaseException; the `async with` in the gateway exists - so a disappearing caller is not silently dropped.""" - import asyncio - - self._hide_sgp_obs(monkeypatch) - with pytest.raises(asyncio.CancelledError): - async with inference_call({"model": "gpt-4o"}): - raise asyncio.CancelledError() - - def test_a_broken_sgp_obs_does_not_break_a_model_call(self, monkeypatch): - """Not just ImportError: anything raised while starting a record must fall - back to the null recorder.""" - module = type(sys)("sgp_obs.metrics") - genai = type(sys)("genai") - - def exploding(**_kwargs): - raise RuntimeError("sgp-obs internals changed") - - genai.call = exploding - genai.CHAT = "chat" - genai.OPENAI_SPEC = "openai" - genai.OPENAI = "openai" - module.genai = genai - monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs")) - monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module) - assert inference_call({"model": "gpt-4o"}) is _genai_metrics._NULL_CALL - - -class TestTheImportIsResolvedOnce: - """Python does not cache a FAILED import, so importing inside ``inference_call`` - re-walked sys.path on every model call. Measured at 62us per attempt with five - sys.path entries, which was most of the gateway's per-call overhead for the - majority of agents -- the ones with no sgp-obs installed.""" - - def test_a_missing_sgp_obs_is_looked_up_once_not_per_call(self, monkeypatch): - attempts = [] - real_import = builtins.__import__ - - def counting_import(name, *args, **kwargs): - if name == "sgp_obs" or name.startswith("sgp_obs."): - attempts.append(name) - raise ImportError("No module named 'sgp_obs'") - return real_import(name, *args, **kwargs) - - for name in [m for m in sys.modules if m.startswith("sgp_obs")]: - monkeypatch.delitem(sys.modules, name, raising=False) - monkeypatch.setattr(builtins, "__import__", counting_import) - - for _ in range(50): - assert inference_call({"model": "gpt-4o"}) is _genai_metrics._NULL_CALL - - assert len(attempts) == 1, f"expected one import attempt, got {len(attempts)}" - - def test_a_present_sgp_obs_is_looked_up_once_too(self, monkeypatch): - """The handle must cache the module as well as the failure, or an agent that - DOES have sgp-obs keeps paying for a lookup it already did.""" - attempts = [] - - class _Genai: - CHAT = "chat" - OPENAI_SPEC = "openai" - OPENAI = "openai" - - @staticmethod - def call(**_kwargs): - return _genai_metrics._NULL_CALL - - module = type(sys)("sgp_obs.metrics") - module.genai = _Genai - monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs")) - monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module) - - real_import = builtins.__import__ - - def counting_import(name, *args, **kwargs): - if name == "sgp_obs" or name.startswith("sgp_obs."): - attempts.append(name) - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", counting_import) - - for _ in range(50): - inference_call({"model": "gpt-4o"}) - - assert len(attempts) == 1, f"expected one import attempt, got {len(attempts)}" - - def test_the_recorder_is_still_the_real_one_after_caching(self, monkeypatch): - """Caching must not turn a working sgp-obs into the null path on call two.""" - seen = [] - - class _Genai: - CHAT = "chat" - OPENAI_SPEC = "openai" - OPENAI = "openai" - - @staticmethod - def call(**kwargs): - seen.append(kwargs["model"]) - return _genai_metrics._NULL_CALL - - module = type(sys)("sgp_obs.metrics") - module.genai = _Genai - monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs")) - monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module) - - for index in range(3): - inference_call({"model": f"anthropic/claude-{index}"}) - - assert seen == ["anthropic/claude-0", "anthropic/claude-1", "anthropic/claude-2"] - - -class TestResolveModel: - """litellm takes `model` as its FIRST positional argument and the gateway forwards - *args untouched, so a positional call is legal and must still be measured. - - Reading only kwargs does not merely mislabel the vendor: an empty model resolves to - the default vendor "openai", which sets transport=OPENAI, which makes call() stand - down for the OpenAI client instrumentor — while litellm routes natively to Anthropic - and never touches that client. Nothing records it and nothing says so. - """ - - def test_keyword_model(self): - assert resolve_model((), {"model": "gpt-4o"}) == "gpt-4o" - - def test_positional_model(self): - assert resolve_model(("anthropic/claude-sonnet-4",), {}) == "anthropic/claude-sonnet-4" - - def test_keyword_wins_over_positional(self): - """litellm itself would reject both, but if it ever resolved one, the keyword is - the explicit intent.""" - assert resolve_model(("a/b",), {"model": "c/d"}) == "c/d" - - def test_no_model_at_all(self): - assert resolve_model((), {}) == "" - - def test_a_non_string_first_arg_is_not_a_model(self): - """*args is forwarded verbatim, so args[0] is whatever the caller passed.""" - assert resolve_model(([{"role": "user"}],), {}) == "" - - def test_positional_native_vendor_does_not_stand_down(self, monkeypatch): - """The regression this guards: a positional Anthropic model must be recorded by - the gateway, because nothing else will.""" - seen = {} - - class _Genai: - CHAT = "chat" - OPENAI_SPEC = "openai" - OPENAI = "openai" - - @staticmethod - def call(**kwargs): - seen.update(kwargs) - return _genai_metrics._NULL_CALL - - module = type(sys)("sgp_obs.metrics") - module.genai = _Genai - monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs")) - monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module) - - inference_call({}, ("anthropic/claude-sonnet-4",)) - assert seen["model"] == "anthropic/claude-sonnet-4" - assert seen["provider"] == "anthropic" - # Empty transport == "no OpenAI-client overlap, so record it here". - assert seen["transport"] == "" diff --git a/src/agentex/lib/core/observability/sgp_obs_setup.py b/src/agentex/lib/core/observability/sgp_obs_setup.py deleted file mode 100644 index 3485e568e..000000000 --- a/src/agentex/lib/core/observability/sgp_obs_setup.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Optional sgp-obs wiring: traces, metrics and logs, switched on by environment. - -Why this lives in the SDK rather than in each agent: the fleet is ~147 agent repos, -and their deployments pin an exact SDK version. Doing the wiring here means an agent -adopts observability by installing ``sgp-obs`` and setting environment, instead of -carrying the wiring code — including the two parts that are easy to get wrong and -fail silently (where ``init()`` is called from, and flushing on the way out). - -``sgp-obs`` is NOT declared as a dependency or an extra of this package. It is not on -public PyPI, and declaring it would make this repo's own uv workspace unresolvable: -``uv sync`` re-locks, locking must resolve every declared optional dependency, and -neither ``--no-extra`` nor ``[tool.uv] override-dependencies`` exempts one. So the -contract is inverted — an agent declares ``sgp-obs[genai-auto,http,otlp]`` itself, -against Scale's curated mirror, and this module wires it if it is importable. Nothing -here imports ``sgp_obs`` outside a ``try``, so a plain ``pip install agentex-sdk`` -behaves exactly as it did before this module existed. - -TWO gates, both of which must pass before anything is recorded: - -1. ``sgp-obs`` must be importable. If it is not, this returns ``"not_installed"``. -2. The environment must ask for it. As of sgp-obs 0.16.0 every signal is opt-in - TWICE: the master switch ``SGP_OBS_ENABLED=true``, AND that signal's - ``*_DISABLED`` variable set to an explicit ``false``. An unset ``*_DISABLED`` - leaves the signal OFF. So the master switch on its own wires nothing at all — - measured on 0.16.0, ``SGP_OBS_ENABLED=true`` alone returns zero handles. All - three signals together need:: - - SGP_OBS_ENABLED=true - SGP_METRICS_DISABLED=false - SGP_TRACES_DISABLED=false - SGP_LOGS_DISABLED=false - - That inverts the advice written against 0.15.0, where traces came on with the - master switch and had to be turned off. This module does not second-guess the - gate — it calls ``init()`` and reports which signals came back — but it does - warn when the master switch is on and nothing wired, because that combination - is otherwise completely silent. - -Metrics additionally need an OTLP endpoint. sgp-obs never builds a MeterProvider -from nothing; in a cluster the OTel Operator's auto-instrumentation normally -supplies one, and agent pods get no injection, so ``OTEL_EXPORTER_OTLP_ENDPOINT`` -has to be on the pod spec. - -Fail-open is absolute: this is telemetry, and no failure here may stop an agent from -starting or serving. Every path returns a status string instead of raising. -""" - -from __future__ import annotations - -import os -import asyncio -import threading -from typing import Any - -from agentex.lib.utils.logging import ( - make_logger, - _reset_for_tests as _logging_reset_for_tests, - route_loggers_to_root, -) - -logger = make_logger(__name__) - -_status: str | None = None - -# Which app, if any, was handed to ``sgp_obs.init()``. ``init()`` is process-wide and is -# not meant to run twice, but the ASGI instrumentation it installs is per-APP — so a -# second application arriving later silently gets none of it. Remembered so that case can -# at least be named; see the warning in :func:`init_sgp_obs`. -_wired_app: Any = None - -# sgp-obs' own truthy set (sgp_obs.env._TRUTHY), so "is the master switch on?" is -# answered the same way here as in the library deciding whether to wire. -_TRUTHY = {"1", "true", "yes", "on"} - -# The logs-profile selector. The SDK knows the runtime is agentex; an agent author -# would have to know to pass it. It stamps agent_id (from AGENT_ID) and task_id (from -# the SDK's streaming contextvar) onto every log record. -_SOURCE = "agentex" - -# Wall-clock budget for the flush below. Deliberately the same 5s as -# SYNC_TRACING_SHUTDOWN_BUDGET_S: the two run back to back out of one pod -# terminationGracePeriodSeconds (30s by default), so together they take a third of it -# at worst and leave the rest for the process to actually exit. -SGP_OBS_SHUTDOWN_BUDGET_S = 5.0 - - -def _master_switch_on() -> bool: - return (os.getenv("SGP_OBS_ENABLED") or "").strip().lower() in _TRUTHY - - -def init_sgp_obs(app: Any = None) -> str: - """Wire sgp-obs if it is installed and enabled. Returns a status; never raises. - - Statuses: ``"not_installed"``, ``"disabled"``, ``"wired:"``, ``"error"``. - - ``app`` is the ACP server. Passing it is what adds ``http.server.*`` for the - agent's own entry point — without it the agent is observable only from the - model call outwards, and its own latency and error rate cannot be alerted on. - It is also what installs the trace-context ingress middleware, so an incoming - ``traceparent`` continues into the agent's spans rather than starting a new trace. - """ - global _status, _wired_app - if _status is not None: - # init() is not meant to run twice, and a Temporal worker plus an ACP - # server can both reach this in one process. - if _status.startswith("wired") and app is not None and app is not _wired_app: - # Everything init() set up process-wide (providers, exporters, the egress - # instrumentation) still applies to this app. What does NOT is the per-app - # ASGI layer, and that is the half nothing else would report. - logger.warning( - "sgp-obs was already initialized %s, so this application does not get " - "the ASGI instrumentation: no http.server.* for its own entry point, " - "and an incoming traceparent starts a new trace instead of continuing " - "one. Everything process-wide (model, egress, logs) is unaffected. " - "init() cannot safely run twice, so construct whichever application " - "serves agent traffic before anything else calls init_sgp_obs() — note " - "AgentexWorker.run() initializes without an app.", - "without an application" if _wired_app is None else "for a different application", - ) - return _status - - try: - # Not resolvable in a normal env: sgp-obs is not a dependency of this - # package and is not on public PyPI. That is the case this branch exists for. - import sgp_obs # type: ignore[import-not-found] - except ImportError: - if _master_switch_on(): - # The operator asked for observability and the package is absent. Silence - # here is the worst outcome, so say what is missing and how to fix it. - logger.warning( - "SGP_OBS_ENABLED is set but sgp-obs is not installed, so no telemetry " - "will be produced. Add sgp-obs[genai-auto,http,otlp] to this agent's " - "dependencies (it resolves from Scale's curated mirror, not public PyPI)." - ) - _status = "not_installed" - return _status - except Exception: # pragma: no cover - a broken install must not stop startup - logger.debug("sgp-obs import failed unexpectedly", exc_info=True) - _status = "error" - return _status - - try: - handles = sgp_obs.init( - app=app, - # Fills OTEL_SERVICE_NAME only when the deployment left it unset or - # blank; the deployment always outranks this. Without either, every - # signal is attributed to service.name="unknown". - service_name=(os.getenv("AGENT_NAME") or "").strip() or None, - source=_SOURCE, - ) - except Exception: # pragma: no cover - sgp_obs.init is itself fail-open - # One deliberate exception to its fail-open rule: under the standard CI - # variable, any logs misconfiguration raises so a build cannot pass while - # logging is broken. Swallowed here regardless — an agent must still serve. - logger.warning("sgp-obs initialization failed; continuing without it", exc_info=True) - _status = "error" - return _status - - if not handles: - if _master_switch_on(): - # 0.16.0's double opt-in: the master switch alone wires nothing, and - # sgp-obs says nothing about it. Name the variables that are missing. - logger.warning( - "SGP_OBS_ENABLED is set but no sgp-obs signal is enabled, so nothing " - "will be exported. Each signal is opt-in separately: set " - "SGP_METRICS_DISABLED=false, SGP_TRACES_DISABLED=false and " - "SGP_LOGS_DISABLED=false for the signals you want. An unset " - "*_DISABLED leaves that signal off." - ) - # Otherwise expected, and the default: an agent with sgp-obs installed still - # records nothing until someone sets the environment. - _status = "disabled" - return _status - - if "logs" in handles: - _hand_logging_to_the_pipeline() - - if "traces" in handles: - _install_openai_agents_bridge() - _warn_if_correlation_backend_mismatched() - - _wired_app = app - _status = "wired:" + ",".join(sorted(handles)) - logger.info("sgp-obs wired (%s)", _status) - return _status - - -def _hand_logging_to_the_pipeline() -> None: - """Stop a second, ungoverned copy of every log record being printed. - - ``agentex.lib.utils.logging.make_logger`` attaches a handler to each module's own - (leaf) logger. sgp-obs' logs pipeline replaces the handlers on the ROOT logger and - deliberately leaves named loggers alone, because a named logger's handler may be - there on purpose. The two are individually correct and together print everything - twice: once in agentex's plain-text format from the leaf, once as pipeline JSON - from root. Measured on sgp-obs 0.16.0, one ``logger.info()`` gave two stdout lines, - and sgp-obs' boot warning named 63 loggers. - - The duplicate is not merely redundant: it is emitted before the pipeline's filters, - so it carries no ``agent_id``/``task_id``, is not governed by the allowlist, and is - not truncated. Measured on dbt-assistant running 0.27.0b1: 123 of 3361 log lines - were the second copy, each one 80 microseconds after its governed twin. - - An agent's OWN modules are covered, not just the SDK's. They call - ``make_logger(__name__)`` too, under the agent's package name, and that is where - the dbt-assistant duplicate came from. See - :func:`~agentex.lib.utils.logging.route_loggers_to_root` for how a handler is - recognised as the SDK's on a logger whose name the SDK cannot predict, why - ``capture_loggers=`` is not the mechanism, and why a third party's handler is left - where it is. - """ - try: - cleared = route_loggers_to_root() - except Exception: # pragma: no cover - telemetry must never break startup - logger.debug("could not hand agentex logging to the sgp-obs logs pipeline", exc_info=True) - return - - if cleared: - # sgp-obs has already logged its "bypass log governance" warning by this point, - # naming loggers this call has just fixed. Say so, or the two lines read as a - # contradiction to whoever is looking at the pod's first second of output. - logger.info( - "routed %d logger(s) through the sgp-obs logs pipeline; any 'bypass log " - "governance' warning above that names an agentex.* logger, or one of this " - "agent's own, was emitted before this ran and no longer applies to it", - cleared, - ) - - -def _install_openai_agents_bridge() -> bool: - """Register sgp-obs' openai-agents trace processor, so a ``Runner`` turn produces - logical model-operation spans. - - This is the one piece of traces wiring ``sgp_obs.init()`` does NOT do for itself. - Measured on 0.16.0 after a plain ``init()`` with the traces signal on: - - GenAI attempt span processor installed - litellm logical adapter installed - httpx / aiohttp egress instrumented - openai-agents bridge NOT installed - - which is why the obs-test agents each carry a hand-written bootstrap that calls it. - It matters more than the others here: roughly 83% of model-calling agents reach the - model through the openai-agents ``Runner``, so without this the dominant path - contributes no logical spans and "traces on" looks like it does nothing. - - Unconditional because ``openai-agents`` is a hard dependency of this SDK, so the - ``agents`` package is importable in every agent. The call is idempotent and returns - False rather than raising when the SDK is somehow absent. - """ - try: - from sgp_obs.traces import install_openai_agents_bridge # type: ignore[import-not-found] - - installed = bool(install_openai_agents_bridge()) - if installed: - logger.debug("sgp-obs openai-agents bridge installed") - _warn_if_openai_agents_tracing_disabled() - else: - # Only reachable if `agents` is not importable, which should not happen - # while openai-agents is a hard dependency — so say so rather than shrug. - logger.warning( - "sgp-obs openai-agents bridge did not install; Runner turns will " - "produce no logical model-operation spans." - ) - return installed - except Exception: # pragma: no cover - telemetry must never break startup - logger.debug("sgp-obs openai-agents bridge unavailable", exc_info=True) - return False - - -def _warn_if_openai_agents_tracing_disabled() -> None: - """Warn when the bridge is installed but openai-agents tracing is switched off. - - ``install_openai_agents_bridge()`` returns True as soon as it registers itself as a - trace processor — it cannot tell whether the provider will ever feed it. If the - agent called ``set_tracing_disabled(True)``, no spans are produced at all, so the - bridge is registered and permanently idle, and nothing says so. - - That is not hypothetical: it is what the openai-agents scaffolds used to do, so - agents generated before this change carry it. Those scaffolds now clear the - processor list instead, which removes the OpenAI exporter (the thing they were - actually trying to avoid) while leaving spans flowing to the bridge. - - Reads a private attribute, so it is fully guarded: a diagnostic must never be the - reason startup fails, and if upstream renames it we simply stop warning. - """ - try: - from agents.tracing import get_trace_provider - - if getattr(get_trace_provider(), "_disabled", False): - logger.warning( - "The sgp-obs openai-agents bridge is installed but openai-agents " - "tracing is disabled, so Runner turns will produce no model spans. " - "Replace set_tracing_disabled(True) with set_trace_processors([]): " - "that still stops traces reaching api.openai.com, but keeps spans " - "flowing to the bridge." - ) - except Exception: # pragma: no cover - a diagnostic must never break startup - logger.debug("could not determine openai-agents tracing state", exc_info=True) - - -def _warn_if_correlation_backend_mismatched() -> None: - """Warn when sgp-obs is exporting OTel traces but the SDK's business-span - correlation is still reading ddtrace. - - The SDK has had its own correlation for a while (core/tracing/obs_span.py). It - writes BOTH directions of the link between a business span and an obs span: - - forward — obs_trace_id / obs_span_id onto the business span's data, so the - SGP tracing UI can pivot to Tempo - backward — agentex.business_span_id / agentex.business_trace_id onto the OTel - span, so Tempo can pivot back - - Which backend it opens that span in is chosen by SGP_OBS_MODE, which defaults to - ``dd_only``. In that mode it opens a ddtrace span, and only if a ddtrace trace is - already active — which on a bare-uvicorn agent it never is. So the wrapper is - never opened, the correlation dict comes back empty, and BOTH edges vanish - silently while the traces signal still reports itself as wired. - - Measured on sgp-obs 0.16.0 with a real business span: mode unset gives zero - exported spans and no ids in either direction; SGP_OBS_MODE=lgtm gives the - span, both tags, and a round trip that closes (the business span's obs_span_id - equals the exported span's span id, and the span's agentex.business_span_id - equals the business span's id). - - Warn rather than set it: SGP_OBS_MODE also steers correlation reads elsewhere, - and an agent genuinely running ddtrace (the Centipede family) would be misread - if this flipped underneath it. The operator picks; this only makes the silent - case audible. - """ - try: - from agentex.lib.core.tracing.obs_ids import LGTM, get_obs_mode - - if get_obs_mode() != LGTM: - logger.warning( - "sgp-obs wired the traces signal (OpenTelemetry), but SGP_OBS_MODE is " - "%r, so this SDK's business-span correlation still targets ddtrace and " - "will not link anything. Set SGP_OBS_MODE=lgtm to get both edges: " - "obs_trace_id/obs_span_id on the business span, and " - "agentex.business_span_id/agentex.business_trace_id on the OTel span.", - get_obs_mode(), - ) - except Exception: # pragma: no cover - a diagnostic must never break startup - logger.debug("could not check SGP_OBS_MODE", exc_info=True) - - -async def shutdown_sgp_obs(budget_s: float = SGP_OBS_SHUTDOWN_BUDGET_S) -> None: - """Flush the providers ``init()`` built, within a deadline. Never raises. - - Without this, whatever is sitting in a periodic exporter's buffer when the pod - stops is dropped — which for a short-lived or scaled-to-zero agent can be most - of what it recorded. sgp-obs only flushes providers it OWNS; one adopted from - the runtime is left to its owner, so this is safe under operator injection. - - Bounded, on a DAEMON thread, for the reason spelled out at length in - ``tracing_processor_manager.shutdown_sync_tracing_processors``: the flush is a - blocking network export whose own timeout may exceed whatever is left of the pod's - grace period, ``asyncio.wait_for`` can stop *awaiting* a thread but cannot stop the - thread, and ``asyncio.run`` joins the default executor on the way out — so an - ``asyncio.to_thread`` flush that timed out would still hold the process open until - the export finished or the pod was killed. A daemon thread is abandoned at - interpreter exit, which is what the budget promises. - - This is the LAST drain in both the ACP lifespan and the worker, so an overrun here - delays nothing else — but it can still burn the grace period the runtime needs to - exit cleanly, which is what the deadline is for. - """ - if _status is None or not _status.startswith("wired"): - return - - try: - import sgp_obs # type: ignore[import-not-found] - - # Added in sgp-obs 0.16.0. Feature-detected rather than version-pinned, - # because this package does not depend on sgp-obs and so cannot set a floor. - shutdown = getattr(sgp_obs, "shutdown", None) - if shutdown is None: - logger.debug("sgp-obs has no shutdown(); needs 0.16.0+ to flush on exit") - return - - loop = asyncio.get_running_loop() - finished = asyncio.Event() - - def _flush() -> None: - try: - shutdown() - except Exception: - logger.debug("sgp-obs shutdown raised", exc_info=True) - finally: - # The loop may already be closed if we timed out and shutdown raced - # ahead; abandoning the notification is fine, nobody is waiting on it. - try: - loop.call_soon_threadsafe(finished.set) - except RuntimeError: # pragma: no cover - loop already closed - pass - - threading.Thread( - target=_flush, daemon=True, name="agentex-sgp-obs-flush" - ).start() - - try: - await asyncio.wait_for(finished.wait(), budget_s) - except (TimeoutError, asyncio.TimeoutError): - logger.warning( - "sgp-obs did not finish flushing within %.1fs; whatever it still held " - "is lost, but shutdown continues", - budget_s, - ) - except Exception: # pragma: no cover - a failed flush must not fail shutdown - logger.debug("sgp-obs shutdown failed", exc_info=True) - - -def _reset_for_tests() -> None: - global _status, _wired_app - _status = None - _wired_app = None - # The logging hand-over is a process-wide latch too, and a test that wired the - # logs signal would otherwise leave make_logger attaching nothing for the rest - # of the session. - _logging_reset_for_tests() diff --git a/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py b/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py deleted file mode 100644 index e39a6f4a9..000000000 --- a/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py +++ /dev/null @@ -1,613 +0,0 @@ -"""Tests for ``agentex.lib.core.observability.sgp_obs_setup``. - -The property under test is that this can never hurt a caller: whatever the state of -sgp-obs or the environment, ``init_sgp_obs`` returns a status string and does not -raise, and ``shutdown_sgp_obs`` does not raise. Both gates get a test, plus the -failure modes, the two silent-misconfiguration warnings, and the flush. - -These never import the real sgp-obs — it is absent in CI by design — so every test -installs a stand-in whose ``init`` is under the test's control. -""" - -from __future__ import annotations - -import sys -import builtins -from contextlib import contextmanager - -import pytest - -from agentex.lib.core.observability import sgp_obs_setup -from agentex.lib.core.observability.sgp_obs_setup import init_sgp_obs, shutdown_sgp_obs - -_SWITCHES = ( - "SGP_OBS_ENABLED", - "SGP_METRICS_DISABLED", - "SGP_TRACES_DISABLED", - "SGP_LOGS_DISABLED", - "AGENT_NAME", -) - - -@pytest.fixture(autouse=True) -def _reset(monkeypatch): - """The status is cached process-wide, so every test starts from unset. The - environment is cleared too: two code paths branch on the master switch, and a - developer with SGP_OBS_ENABLED exported would otherwise flip those tests.""" - for name in _SWITCHES: - monkeypatch.delenv(name, raising=False) - sgp_obs_setup._reset_for_tests() - yield - sgp_obs_setup._reset_for_tests() - - -@contextmanager -def caplog_at(monkeypatch): - """Collect sgp_obs_setup's WARNING messages regardless of root config.""" - records: list[str] = [] - monkeypatch.setattr( - sgp_obs_setup.logger, "warning", - lambda msg, *a, **_k: records.append(msg % a if a else msg), - ) - yield records - - -def _fake_sgp_obs(monkeypatch, init=None, shutdown=None, bridge=None): - """Install a stand-in ``sgp_obs`` module whose entry points we control. - - ``bridge`` stands in for ``sgp_obs.traces.install_openai_agents_bridge``; it lives - on a fake ``sgp_obs.traces`` submodule because that is how the SDK imports it. - """ - module = type(sys)("sgp_obs") - module.init = init if init is not None else (lambda **_kwargs: {"metrics": object()}) - if shutdown is not None: - module.shutdown = shutdown - monkeypatch.setitem(sys.modules, "sgp_obs", module) - - traces = type(sys)("sgp_obs.traces") - traces.install_openai_agents_bridge = bridge if bridge is not None else (lambda: True) - monkeypatch.setitem(sys.modules, "sgp_obs.traces", traces) - return module - - -def _block_sgp_obs_import(monkeypatch, exc=None): - monkeypatch.delitem(sys.modules, "sgp_obs", raising=False) - real_import = builtins.__import__ - error = exc or ImportError("No module named 'sgp_obs'") - - def blocked(name, *args, **kwargs): - if name == "sgp_obs" or name.startswith("sgp_obs."): - raise error - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", blocked) - - -class TestGateOneSgpObsNotInstalled: - def test_missing_package_is_reported_not_raised(self, monkeypatch): - _block_sgp_obs_import(monkeypatch) - assert init_sgp_obs() == "not_installed" - - def test_a_broken_install_does_not_stop_startup(self, monkeypatch): - """An ImportError is ordinary; anything else is a broken install, not a - missing one, and must still be swallowed.""" - _block_sgp_obs_import(monkeypatch, RuntimeError("half-installed wheel")) - assert init_sgp_obs() == "error" - - def test_silence_is_expected_when_nobody_asked(self, monkeypatch, caplog): - """sgp-obs is not a dependency, so absent-and-unasked-for is the normal - case for every agent. It must not warn.""" - _block_sgp_obs_import(monkeypatch) - with caplog.at_level("WARNING"): - assert init_sgp_obs() == "not_installed" - assert caplog.records == [] - - def test_enabled_but_missing_says_what_to_install(self, monkeypatch, caplog): - """The one case that must be loud: the operator asked for observability and - the package is not there. Silence would look like working instrumentation.""" - monkeypatch.setenv("SGP_OBS_ENABLED", "true") - _block_sgp_obs_import(monkeypatch) - with caplog.at_level("WARNING"): - assert init_sgp_obs() == "not_installed" - assert len(caplog.records) == 1 - assert "sgp-obs is not installed" in caplog.text - assert "genai-auto,http,otlp" in caplog.text - - -class TestGateTwoEnvironmentSwitches: - def test_no_handles_means_disabled(self, monkeypatch): - """sgp_obs.init() returns an empty dict when the master switch or every - per-signal switch is off. That is the DEFAULT: sgp-obs installed, and - recording nothing until someone sets the environment.""" - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {}) - assert init_sgp_obs() == "disabled" - - def test_disabled_and_unasked_for_is_quiet(self, monkeypatch, caplog): - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {}) - with caplog.at_level("WARNING"): - assert init_sgp_obs() == "disabled" - assert caplog.records == [] - - def test_master_switch_on_but_nothing_wired_names_the_variables( - self, monkeypatch, caplog - ): - """sgp-obs 0.16.0 made every signal opt-in twice: the master switch plus an - explicit *_DISABLED=false. So SGP_OBS_ENABLED on its own wires nothing and - says nothing, which is the single easiest way to believe an agent is - instrumented when it is not.""" - monkeypatch.setenv("SGP_OBS_ENABLED", "true") - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {}) - with caplog.at_level("WARNING"): - assert init_sgp_obs() == "disabled" - assert len(caplog.records) == 1 - for var in ("SGP_METRICS_DISABLED", "SGP_TRACES_DISABLED", "SGP_LOGS_DISABLED"): - assert var in caplog.text - - @pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on"]) - def test_master_switch_truthy_forms(self, monkeypatch, caplog, raw): - """Matched to sgp_obs.env._TRUTHY, so this module's idea of "on" is the - same as the library's. A mismatch would put the warning on the wrong side.""" - monkeypatch.setenv("SGP_OBS_ENABLED", raw) - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {}) - with caplog.at_level("WARNING"): - init_sgp_obs() - assert len(caplog.records) == 1 - - def test_traces_without_lgtm_mode_warns_that_correlation_is_dead( - self, monkeypatch - ): - """SGP_OBS_MODE defaults to dd_only, where the SDK's business-span wrapper - only opens if a ddtrace trace is already active — never true on a - bare-uvicorn agent. So both correlation edges vanish while the traces - signal still reports itself wired. Measured: mode unset -> zero exported - spans and no ids either way; lgtm -> both edges, round trip closes.""" - monkeypatch.delenv("SGP_OBS_MODE", raising=False) - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"traces": object()}) - with caplog_at(monkeypatch) as records: - assert init_sgp_obs() == "wired:traces" - assert any("SGP_OBS_MODE" in r for r in records) - - def test_traces_with_lgtm_mode_is_quiet(self, monkeypatch, caplog): - monkeypatch.setenv("SGP_OBS_MODE", "lgtm") - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"traces": object()}) - with caplog.at_level("WARNING"): - assert init_sgp_obs() == "wired:traces" - assert caplog.records == [] - - def test_metrics_only_does_not_warn_about_the_mode(self, monkeypatch, caplog): - """The correlation edges are a traces concern. A metrics-only agent has no - business-span linking to lose, so the warning would be noise.""" - monkeypatch.delenv("SGP_OBS_MODE", raising=False) - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"metrics": object()}) - with caplog.at_level("WARNING"): - assert init_sgp_obs() == "wired:metrics" - assert caplog.records == [] - - def test_all_three_signals_are_named_in_the_status(self, monkeypatch): - _fake_sgp_obs( - monkeypatch, - lambda **_kwargs: {"logs": object(), "metrics": object(), "traces": object()}, - ) - assert init_sgp_obs() == "wired:logs,metrics,traces" - - -class TestWhatIsPassedToSgpObs: - @staticmethod - def _capture(monkeypatch): - seen = {} - - def capture(**kwargs): - seen.update(kwargs) - return {"metrics": object()} - - _fake_sgp_obs(monkeypatch, capture) - return seen - - def test_app_reaches_sgp_obs(self, monkeypatch): - """Passing the ACP server is what adds http.server.* for the agent's own - entry point and installs the trace-context ingress, so it must not be - silently dropped.""" - seen = self._capture(monkeypatch) - sentinel = object() - init_sgp_obs(app=sentinel) - assert seen["app"] is sentinel - - def test_source_is_agentex(self, monkeypatch): - """The SDK knows the runtime; an agent author would have to know to pass it. - It is what stamps agent_id and task_id onto log records.""" - seen = self._capture(monkeypatch) - init_sgp_obs() - assert seen["source"] == "agentex" - - def test_agent_name_is_offered_as_the_service_name(self, monkeypatch): - """sgp-obs fills OTEL_SERVICE_NAME from this only when the deployment left - it unset; without either, every signal is attributed to "unknown".""" - monkeypatch.setenv("AGENT_NAME", "compass-sleep-agent") - seen = self._capture(monkeypatch) - init_sgp_obs() - assert seen["service_name"] == "compass-sleep-agent" - - @pytest.mark.parametrize("raw", ["", " "]) - def test_blank_agent_name_is_passed_as_none(self, monkeypatch, raw): - """Blank is the Helm rendered-empty idiom. Forwarding "" would have sgp-obs - set OTEL_SERVICE_NAME to an empty string rather than leave it alone.""" - monkeypatch.setenv("AGENT_NAME", raw) - seen = self._capture(monkeypatch) - init_sgp_obs() - assert seen["service_name"] is None - - -class TestFailOpen: - def test_an_exception_from_init_is_swallowed(self, monkeypatch): - def boom(**_kwargs): - raise ValueError("boom") - - _fake_sgp_obs(monkeypatch, boom) - assert init_sgp_obs() == "error" - - def test_a_ci_logs_misconfiguration_still_does_not_stop_startup(self, monkeypatch): - """sgp_obs.init has one deliberate exception to its own fail-open rule: under - the CI variable, a logs misconfiguration raises. An agent must still serve.""" - - def strict(**_kwargs): - raise RuntimeError("MisconfigurationError: drop mode without an allowlist") - - _fake_sgp_obs(monkeypatch, strict) - assert init_sgp_obs() == "error" - - def test_status_is_computed_once(self, monkeypatch): - """A Temporal worker and an ACP server can both reach this in one process; - sgp_obs.init() is not meant to run twice.""" - calls = [] - - def counting(**kwargs): - calls.append(kwargs) - return {"metrics": object()} - - _fake_sgp_obs(monkeypatch, counting) - assert init_sgp_obs() == "wired:metrics" - assert init_sgp_obs() == "wired:metrics" - assert len(calls) == 1 - - -class TestTheFlushIsBounded: - """``sgp_obs.shutdown()`` is a blocking network export whose own timeout may be - longer than whatever is left of the pod's grace period. Both callers (the ACP - lifespan and the worker's finally) await it, so an unbounded flush held the - process open until the export finished or the pod was killed.""" - - async def test_a_stalled_flush_returns_within_the_budget(self, monkeypatch): - import time as _time - - _fake_sgp_obs(monkeypatch, shutdown=lambda: _time.sleep(30)) - assert init_sgp_obs() == "wired:metrics" - - started = _time.monotonic() - await shutdown_sgp_obs(budget_s=0.25) - elapsed = _time.monotonic() - started - assert elapsed < 5, f"waited {elapsed:.1f}s on a 0.25s budget" - - async def test_the_overrun_is_reported(self, monkeypatch): - """Silence here would look exactly like a clean flush, while the telemetry - the flush existed to save is gone.""" - import time as _time - - _fake_sgp_obs(monkeypatch, shutdown=lambda: _time.sleep(30)) - assert init_sgp_obs() == "wired:metrics" - with caplog_at(monkeypatch) as records: - await shutdown_sgp_obs(budget_s=0.05) - assert any("did not finish flushing" in r for r in records), records - - async def test_a_prompt_flush_is_not_delayed_by_the_budget(self, monkeypatch): - """The deadline is a ceiling, not a wait.""" - import time as _time - - called = [] - _fake_sgp_obs(monkeypatch, shutdown=lambda: called.append(True)) - assert init_sgp_obs() == "wired:metrics" - started = _time.monotonic() - await shutdown_sgp_obs(budget_s=30) - assert _time.monotonic() - started < 5 - assert called == [True] - - async def test_it_does_not_block_the_event_loop(self, monkeypatch): - """The flush runs off the loop, so the lifespan can still make progress.""" - import time as _time - import asyncio as _asyncio - - _fake_sgp_obs(monkeypatch, shutdown=lambda: _time.sleep(1.0)) - assert init_sgp_obs() == "wired:metrics" - - ticks = 0 - - async def tick(): - nonlocal ticks - while True: - await _asyncio.sleep(0.01) - ticks += 1 - - ticker = _asyncio.create_task(tick()) - await shutdown_sgp_obs(budget_s=0.3) - ticker.cancel() - assert ticks > 3, f"loop only advanced {ticks} times; the flush blocked it" - - - def test_a_stalled_flush_does_not_delay_process_exit(self): - """The property the deadline actually promises, and the one it did NOT have. - - ``asyncio.wait_for`` stops awaiting a thread; it cannot stop the thread. And - ``asyncio.run`` calls ``loop.shutdown_default_executor()``, which JOINS the - default executor — so the previous ``asyncio.to_thread(shutdown)`` returned at - the deadline but left the process blocked on the very export the deadline was - meant to escape. A daemon thread is abandoned at interpreter exit. - - A subprocess, because this is about interpreter shutdown: it cannot be observed - from inside the test process. - """ - import os - import sys - import time - import shutil - import tempfile - import textwrap - import subprocess - from pathlib import Path - - # tests/observability/core/lib/agentex/src -> parents[5] is the src root. - src = Path(__file__).resolve().parents[5] - stub_dir = tempfile.mkdtemp() - try: - # A real importable sgp_obs, so the subprocess takes the wired path. - Path(stub_dir, "sgp_obs.py").write_text( - "import time\n" - "def init(**kwargs):\n" - " return {'metrics': object()}\n" - "def shutdown():\n" - " time.sleep(30)\n" - ) - program = textwrap.dedent( - """ - import asyncio - from agentex.lib.core.observability.sgp_obs_setup import ( - init_sgp_obs, shutdown_sgp_obs, - ) - - assert init_sgp_obs().startswith("wired"), "stub did not wire" - asyncio.run(shutdown_sgp_obs(budget_s=0.25)) - """ - ) - started = time.monotonic() - proc = subprocess.run( - [sys.executable, "-c", program], - capture_output=True, - text=True, - timeout=30, - env={**os.environ, "PYTHONPATH": os.pathsep.join([stub_dir, str(src)])}, - ) - elapsed = time.monotonic() - started - finally: - shutil.rmtree(stub_dir, ignore_errors=True) - - assert proc.returncode == 0, proc.stderr[-2000:] - assert elapsed < 10, ( - f"process took {elapsed:.1f}s to exit with a 30s stalled flush and a " - "0.25s budget; the flush thread is blocking interpreter shutdown" - ) - - -class TestASecondAppIsNotSilentlyUninstrumented: - """``init()`` is process-wide and must not run twice, but the ASGI instrumentation - it installs is per-APP. A second application therefore gets none of it — and that - is the half nothing else would report.""" - - async def test_a_second_app_is_warned_about(self, monkeypatch): - _fake_sgp_obs(monkeypatch) - first, second = object(), object() - assert init_sgp_obs(app=first) == "wired:metrics" - with caplog_at(monkeypatch) as records: - assert init_sgp_obs(app=second) == "wired:metrics" - assert any("does not get the ASGI instrumentation" in r for r in records), records - - async def test_the_worker_then_acp_ordering_is_named(self, monkeypatch): - """The realistic case: AgentexWorker.run() calls init_sgp_obs() with no app, so - an ACP server built later in the same process would lose http.server.*.""" - _fake_sgp_obs(monkeypatch) - assert init_sgp_obs() == "wired:metrics" - with caplog_at(monkeypatch) as records: - init_sgp_obs(app=object()) - assert any("without an application" in r for r in records), records - - async def test_the_same_app_twice_is_quiet(self, monkeypatch): - """Re-entry with the same app is just the idempotence guard doing its job.""" - _fake_sgp_obs(monkeypatch) - app = object() - assert init_sgp_obs(app=app) == "wired:metrics" - with caplog_at(monkeypatch) as records: - assert init_sgp_obs(app=app) == "wired:metrics" - assert records == [] - - async def test_nothing_is_warned_when_nothing_was_wired(self, monkeypatch): - """With sgp-obs absent there is no instrumentation for a second app to miss, - so this must not add noise to the overwhelmingly common case.""" - _block_sgp_obs_import(monkeypatch) - assert init_sgp_obs() == "not_installed" - with caplog_at(monkeypatch) as records: - assert init_sgp_obs(app=object()) == "not_installed" - assert records == [] - - -class TestShutdown: - async def test_flushes_when_wired(self, monkeypatch): - """Without this the periodic exporter's buffer is dropped when the pod - stops, which for a short-lived agent can be most of what it recorded.""" - called = [] - _fake_sgp_obs(monkeypatch, shutdown=lambda: called.append(True)) - assert init_sgp_obs() == "wired:metrics" - await shutdown_sgp_obs() - assert called == [True] - - async def test_no_flush_when_never_wired(self, monkeypatch): - called = [] - _fake_sgp_obs( - monkeypatch, init=lambda **_kwargs: {}, shutdown=lambda: called.append(True) - ) - assert init_sgp_obs() == "disabled" - await shutdown_sgp_obs() - assert called == [] - - async def test_no_flush_before_init(self, monkeypatch): - """Called from the lifespan's finally, which runs even if startup failed - before the constructor's init_sgp_obs ever ran.""" - called = [] - _fake_sgp_obs(monkeypatch, shutdown=lambda: called.append(True)) - await shutdown_sgp_obs() - assert called == [] - - async def test_an_older_sgp_obs_without_shutdown_is_tolerated(self, monkeypatch): - """shutdown() arrived in 0.16.0. This package declares no dependency on - sgp-obs and so cannot set a floor, hence feature detection.""" - _fake_sgp_obs(monkeypatch) # no shutdown attribute - assert init_sgp_obs() == "wired:metrics" - await shutdown_sgp_obs() # must not raise - - async def test_a_failing_flush_does_not_fail_shutdown(self, monkeypatch): - def boom(): - raise RuntimeError("exporter timed out") - - _fake_sgp_obs(monkeypatch, shutdown=boom) - assert init_sgp_obs() == "wired:metrics" - await shutdown_sgp_obs() # must not raise - - -class TestAnAgentStillServesWithoutSgpObs: - """Nitesh's verification item, startup half: an account not yet on the - CodeArtifact allowlist gets an image with no ``sgp_obs`` in it. The gate - returning ``not_installed`` is necessary but not sufficient — what has to hold - is that the ACP server still constructs and still answers requests. This - exercises the real constructor, which is where ``init_sgp_obs`` is called. - """ - - def test_acp_server_constructs_and_serves_healthz(self, monkeypatch): - from fastapi.testclient import TestClient - - from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer - - # Import first, unpatched, so the deep FastACP dependency chain loads - # cleanly; only sgp_obs is hidden, and only while the constructor runs. - _block_sgp_obs_import(monkeypatch) - - server = BaseACPServer() - assert sgp_obs_setup._status == "not_installed" - - # No `with`: that would run the lifespan, which registers the agent - # against a live control plane. - response = TestClient(server).get("/healthz") - assert response.status_code == 200 - assert response.json() == {"status": "healthy"} - - def test_the_json_rpc_route_is_still_mounted(self, monkeypatch): - """A server that answers /healthz but lost /api would pass a liveness probe - and fail every actual request.""" - from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer - - _block_sgp_obs_import(monkeypatch) - routes = {getattr(r, "path", None) for r in BaseACPServer().routes} - assert {"/healthz", "/api"} <= routes - - -class TestOpenAIAgentsBridge: - """sgp_obs.init() installs the GenAI attempt processor, the litellm adapter and the - egress instrumentors by itself, but NOT the openai-agents bridge (measured on - 0.16.0). That is the path ~83% of model-calling agents take, so the SDK installs it - — otherwise "traces on" produces no logical model-operation spans for most agents. - """ - - def test_installed_when_traces_are_wired(self, monkeypatch): - calls = [] - _fake_sgp_obs( - monkeypatch, - init=lambda **_kwargs: {"traces": object()}, - bridge=lambda: calls.append(True) or True, - ) - monkeypatch.setenv("SGP_OBS_MODE", "lgtm") - assert init_sgp_obs() == "wired:traces" - assert calls == [True] - - def test_not_installed_without_the_traces_signal(self, monkeypatch): - """A metrics-only agent has no span pipeline to feed, so installing an - openai-agents trace processor would be pointless work at startup.""" - calls = [] - _fake_sgp_obs( - monkeypatch, - init=lambda **_kwargs: {"metrics": object()}, - bridge=lambda: calls.append(True) or True, - ) - assert init_sgp_obs() == "wired:metrics" - assert calls == [] - - def test_a_bridge_that_declines_is_reported(self, monkeypatch, caplog): - """False means the `agents` SDK was not importable. openai-agents is a hard - dependency of this package, so that should be impossible — say so rather than - swallow it.""" - monkeypatch.setenv("SGP_OBS_MODE", "lgtm") - _fake_sgp_obs( - monkeypatch, init=lambda **_kwargs: {"traces": object()}, bridge=lambda: False - ) - with caplog.at_level("WARNING"): - assert init_sgp_obs() == "wired:traces" - assert "openai-agents bridge" in caplog.text - - def test_a_raising_bridge_does_not_stop_startup(self, monkeypatch): - def boom(): - raise RuntimeError("sgp-obs internals moved") - - monkeypatch.setenv("SGP_OBS_MODE", "lgtm") - _fake_sgp_obs( - monkeypatch, init=lambda **_kwargs: {"traces": object()}, bridge=boom - ) - assert init_sgp_obs() == "wired:traces" - - -class TestLoggingHandover: - """agentex's make_logger attaches a handler to each module's own logger — the - agent's modules as well as the SDK's; sgp-obs' logs pipeline owns the ROOT logger and - deliberately leaves named loggers alone. Both then print, so every record appears - twice — and the leaf copy is emitted before the pipeline's filters, so it carries no - agent_id/task_id, is not governed by the allowlist, and is not truncated. - """ - - @staticmethod - def _spy(monkeypatch): - calls = [] - monkeypatch.setattr( - sgp_obs_setup, "route_loggers_to_root", lambda: calls.append(True) or 1 - ) - return calls - - def test_handover_runs_when_the_logs_signal_is_wired(self, monkeypatch): - calls = self._spy(monkeypatch) - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"logs": object()}) - assert init_sgp_obs() == "wired:logs" - assert calls == [True] - - def test_no_handover_when_logs_are_not_wired(self, monkeypatch): - """Nothing owns the root logger in that case, so stripping the leaf handlers - would send agentex's records nowhere at all.""" - calls = self._spy(monkeypatch) - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"metrics": object()}) - assert init_sgp_obs() == "wired:metrics" - assert calls == [] - - def test_no_handover_when_sgp_obs_is_absent(self, monkeypatch): - calls = self._spy(monkeypatch) - _block_sgp_obs_import(monkeypatch) - assert init_sgp_obs() == "not_installed" - assert calls == [] - - def test_a_failing_handover_does_not_stop_startup(self, monkeypatch): - def boom(): - raise RuntimeError("logging registry is in a strange state") - - monkeypatch.setattr(sgp_obs_setup, "route_loggers_to_root", boom) - _fake_sgp_obs(monkeypatch, lambda **_kwargs: {"logs": object()}) - assert init_sgp_obs() == "wired:logs" diff --git a/src/agentex/lib/core/temporal/logging.py b/src/agentex/lib/core/temporal/logging.py deleted file mode 100644 index 094388525..000000000 --- a/src/agentex/lib/core/temporal/logging.py +++ /dev/null @@ -1,34 +0,0 @@ -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 26dce2994..893f75f28 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,10 +22,8 @@ ) from temporalio.converter import default -from agentex.lib.core.temporal.logging import WorkflowLoggerAdapter - # Set up logging -logger = WorkflowLoggerAdapter(logging.getLogger("context.interceptor"), {}) +logger = 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 ba8f87de5..9f0aa2da3 100644 --- a/src/agentex/lib/core/temporal/workers/worker.py +++ b/src/agentex/lib/core/temporal/workers/worker.py @@ -31,10 +31,7 @@ from agentex.lib.utils.registration import register_agent from agentex.lib.core.tracing.temporal import temporal_tracing_interceptors from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.tracing.span_queue import shutdown_default_span_queue from agentex.lib.core.compat.version_guard import assert_backend_compatible -from agentex.lib.core.observability.sgp_obs_setup import init_sgp_obs, shutdown_sgp_obs -from agentex.lib.core.tracing.tracing_processor_manager import shutdown_sync_tracing_processors logger = make_logger(__name__) @@ -181,7 +178,6 @@ 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 = [] @@ -200,7 +196,6 @@ 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( @@ -225,18 +220,6 @@ async def run( workflow: type | None = None, workflows: list[type] | None = None, ): - # A Temporal agent runs its model calls HERE, in a separate process from the - # ACP server, and this process never constructs a BaseACPServer — so without - # this call an agent that installed sgp-obs and set the documented environment - # would still get no metrics, traces or structured logs from its worker, which - # is where the interesting work happens. - # - # No `app=`: there is no ASGI application in this process. The health-check - # server is aiohttp, which sgp-obs' ASGI middleware does not apply to, so the - # worker contributes model and egress telemetry but no http.server.* — correct, - # since nothing here serves agent traffic. - init_sgp_obs() - await self.start_health_check_server() await self._register_agent() @@ -273,29 +256,16 @@ 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 - # Temporal inherits client tracing before these business interceptors. - interceptors=self.interceptors, + # Tracing interceptor OUTERMOST so business interceptors (and the spans + # they create) nest under the propagated workflow/activity span. + interceptors=[*temporal_tracing_interceptors(), *self.interceptors], ) logger.info(f"Starting workers for task queue: {self.task_queue}") # Eagerly set the worker status to healthy self.healthy = True logger.info(f"Running workers for task queue: {self.task_queue}") - try: - await worker.run() - finally: - # The same three drains as the ACP lifespan, in the same order and for the - # same reason: whatever is still queued when the pod stops is otherwise - # dropped. All three are bounded and fail-open, so none can stop the worker - # exiting. - # - # The async queue matters here specifically: standard Temporal activities - # trace through AsyncTracer (core/temporal/activities/__init__.py), and - # AsyncTrace takes get_default_span_queue() when no queue is passed, so a - # worker's business spans sit in exactly this queue. - await shutdown_default_span_queue() - await shutdown_sync_tracing_processors() - await shutdown_sgp_obs() + await worker.run() async def _health_check(self): return web.json_response(self.healthy) @@ -342,6 +312,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, agent_card=self.agent_card) + await register_agent(env_vars) else: logger.warning("AGENTEX_BASE_URL not set, skipping worker registration") diff --git a/src/agentex/lib/core/temporal/workflows/workflow.py b/src/agentex/lib/core/temporal/workflows/workflow.py index 8b638cf8a..e47fd9a5c 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.core.temporal.logging import make_workflow_logger +from agentex.lib.utils.logging import make_logger from agentex.lib.core.temporal.types.workflow import SignalName -logger = make_workflow_logger(__name__) +logger = make_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 570d4f1cd..7b08dd45f 100644 --- a/src/agentex/lib/core/tracing/code_revision.py +++ b/src/agentex/lib/core/tracing/code_revision.py @@ -1,11 +1,10 @@ -"""Stamping of the agent's source commit onto its spans. +"""Opt-in stamping of the agent's source commit onto its spans. -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__:``. +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 @@ -22,7 +21,7 @@ from agentex.lib.utils.logging import make_logger -__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha", "is_git_object_name") +__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha") logger = make_logger(__name__) @@ -32,12 +31,6 @@ # 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" @@ -49,16 +42,13 @@ def is_git_object_name(value: str) -> bool: def enable(commit_sha: str | None = None) -> None: - """Turn on stamping ``__commit_sha__`` onto every span from this process. + """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. - - 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 @@ -113,12 +103,3 @@ 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/core/tracing/tracing_processor_manager.py b/src/agentex/lib/core/tracing/tracing_processor_manager.py index 5227e891c..07c440313 100644 --- a/src/agentex/lib/core/tracing/tracing_processor_manager.py +++ b/src/agentex/lib/core/tracing/tracing_processor_manager.py @@ -1,8 +1,5 @@ from __future__ import annotations -import asyncio -import logging -import threading from typing import TYPE_CHECKING from threading import Lock @@ -81,104 +78,3 @@ def get_sync_tracing_processors(): def get_async_tracing_processors(): return GLOBAL_TRACING_PROCESSOR_MANAGER.get_async_processors() - - -_logger = logging.getLogger(__name__) - -# Total wall-clock budget for draining every sync tracing processor. A pod's -# terminationGracePeriodSeconds (30s by default) is shared with the OTel flush that -# follows this, so the drain takes a small slice of it. -SYNC_TRACING_SHUTDOWN_BUDGET_S = 5.0 - - -async def shutdown_sync_tracing_processors( - budget_s: float = SYNC_TRACING_SHUTDOWN_BUDGET_S, -) -> None: - """Drain the sync tracing processors' queues at shutdown. Never raises. - - Nothing used to call this. The ACP lifespan drained ``shutdown_default_span_queue``, - which is the ASYNC path only, so a sync agent dropped whatever business spans were - still queued when the pod stopped. That matters beyond the lost spans: the business - span is what an obs span's ``agentex.business_trace_id`` resolves to, so losing it - breaks the pivot from Tempo back to the SGP store. - - ``SGPSyncTracingProcessor.shutdown`` calls ``flush_queue()``, a BLOCKING HTTP flush - with retries, so three properties have to hold at once: - - **Off the calling loop.** Awaiting it inline stalls the lifespan, so a slow - collector could burn the pod's whole termination grace period and stop the OTel - flush that runs after this — trading a few business spans for all of the OTel ones. - - **Concurrent.** Every processor is started at once and they share one deadline. A - sequential loop would let the first stalled processor spend the entire budget, so - later processors were skipped even when they would have finished instantly. - - **On DAEMON threads, not the default executor.** This is the subtle one. - ``asyncio.wait_for`` stops *awaiting* a thread; it cannot stop the thread. And - ``asyncio.run`` calls ``loop.shutdown_default_executor()``, which JOINS the default - executor — as does a private ``ThreadPoolExecutor``, via its atexit hook. So a - timed-out ``asyncio.to_thread`` flush leaves the process blocked on the very export - the deadline was meant to escape. Measured: a 10s stalled flush under a 0.25s budget - returns in 0.25s but the process exits at 10.0s with ``to_thread``, and at 0.25s on - a daemon thread. A daemon thread is abandoned at interpreter exit, which is what the - budget promises. - """ - try: - processors = get_sync_tracing_processors() - except Exception: # pragma: no cover - nothing to drain - _logger.debug("sync tracing processors unavailable at shutdown", exc_info=True) - return - - if not processors: - return - - loop = asyncio.get_running_loop() - finished: list[threading.Event] = [] - all_done = asyncio.Event() - - def _note_finished() -> None: - if all(event.is_set() for event in finished): - all_done.set() - - def _flush(processor: SyncTracingProcessor, event: threading.Event) -> None: - try: - processor.shutdown() - except Exception: - _logger.warning( - "%s raised while flushing on shutdown; some business spans may be lost", - type(processor).__name__, - exc_info=True, - ) - finally: - event.set() - # The loop may already be closed if we timed out and shutdown raced ahead; - # abandoning the notification is fine, nobody is waiting on it any more. - try: - loop.call_soon_threadsafe(_note_finished) - except RuntimeError: # pragma: no cover - loop already closed - pass - - for index, processor in enumerate(processors): - event = threading.Event() - finished.append(event) - threading.Thread( - target=_flush, - args=(processor, event), - daemon=True, - name=f"agentex-span-flush-{index}", - ).start() - - try: - await asyncio.wait_for(all_done.wait(), budget_s) - except (TimeoutError, asyncio.TimeoutError): - stalled = [ - type(processor).__name__ - for processor, event in zip(processors, finished) - if not event.is_set() - ] - _logger.warning( - "sync tracing shutdown budget of %.1fs expired with %s still flushing; " - "their business spans are lost, but shutdown continues", - budget_s, - ", ".join(stalled) or "unknown processors", - ) diff --git a/src/agentex/lib/environment_variables.py b/src/agentex/lib/environment_variables.py index dae1e5db3..00dbbaada 100644 --- a/src/agentex/lib/environment_variables.py +++ b/src/agentex/lib/environment_variables.py @@ -26,7 +26,6 @@ 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" @@ -69,11 +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, 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). + # 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 - # 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/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py index 50c304c92..864b466d0 100644 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -39,8 +39,6 @@ FASTACP_HEADER_SKIP_EXACT, FASTACP_HEADER_SKIP_PREFIXES, ) -from agentex.lib.core.observability.sgp_obs_setup import init_sgp_obs, shutdown_sgp_obs -from agentex.lib.core.tracing.tracing_processor_manager import shutdown_sync_tracing_processors logger = make_logger(__name__) @@ -86,71 +84,6 @@ def _attach_incoming_otel_context(scope_headers: list[tuple[bytes, bytes]]) -> o return None -# sgp-obs is an optional install (see ``sgp_obs_setup``), and Python does not cache a -# FAILED import, so attempting one per request would re-walk sys.path for the majority -# of agents that do not have it. Resolved once, to the module or to None. -_OBS_CONTEXT_UNRESOLVED = object() -_obs_context_module: Any = _OBS_CONTEXT_UNRESOLVED - - -def _sgp_obs_context() -> Any | None: - global _obs_context_module - if _obs_context_module is _OBS_CONTEXT_UNRESOLVED: - try: - from sgp_obs import context as obs_context # type: ignore[import-not-found] - - _obs_context_module = obs_context - except Exception: # pragma: no cover - the normal case: sgp-obs is not installed - _obs_context_module = None - return _obs_context_module - - -def _bind_request_id_for_telemetry(request_id: str) -> object | None: - """Put the request id where a logs pipeline reads it from. - - Until the logging hand-over, ``request_id`` reached the logs through exactly one - writer: ``CustomJSONFormatter``, on the handler ``make_logger`` attaches to each - module's own logger. That handler is taken off once a pipeline owns the root logger, - because it was printing a second, ungoverned copy of every record -- and it was the - field's only writer, so without this the request id would not move to the governed - copy, it would disappear. Measured on dbt-assistant: ``request_id`` appeared on 5.2% - of log lines, which were exactly the ungoverned copies. - - sgp-obs reads the id from its shared correlation context -- the one place all three - signals take correlation ids from -- and stamps it onto each record in a stage that - runs on a COPY of the record at handler time. That is why the id is handed over - rather than written onto the record here: ``extra={"request_id": ...}`` from a - caller and an attribute set before the call would collide, and the stdlib raises - ``KeyError`` for that collision at the ``logger.info()`` call site. - - sgp-obs can also fill this context from its own ``RequestIdMiddleware``. Binding the - SDK's id here instead keeps ONE generator for the value, so the id in the logs is - the same one ``ctx_var_request_id`` gives application code and the same one - ``x-request-id`` carried in. - - Returns a reset token (or None); fail-open. - """ - obs_context = _sgp_obs_context() - if obs_context is None: - return None - try: - return obs_context.bind(request_id=request_id) - except Exception: # pragma: no cover - obs must never break a request - return None - - -def _unbind_request_id_for_telemetry(token: object | None) -> None: - if token is None: - return - obs_context = _sgp_obs_context() - if obs_context is None: - return - try: - obs_context.reset(token) - except Exception: # pragma: no cover - best-effort - pass - - def _detach_otel_context(token: object | None) -> None: if token is None: return @@ -170,16 +103,12 @@ def __init__(self, app: ASGIApp) -> None: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: otel_token: object | None = None - obs_request_token: object | None = None if scope["type"] == "http": scope_headers = scope.get("headers", []) headers = dict(scope_headers) raw_request_id = headers.get(b"x-request-id", b"") request_id = raw_request_id.decode() if raw_request_id else uuid.uuid4().hex ctx_var_request_id.set(request_id) - # Keep the id in the logs once the leaf handler that used to write it is - # gone; see _bind_request_id_for_telemetry. - obs_request_token = _bind_request_id_for_telemetry(request_id) # Continue the ingress trace for this request (and its background # Temporal dispatch); see _attach_incoming_otel_context. otel_token = _attach_incoming_otel_context(scope_headers) @@ -187,7 +116,6 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) finally: _detach_otel_context(otel_token) - _unbind_request_id_for_telemetry(obs_request_token) class BaseACPServer(FastAPI): @@ -211,20 +139,6 @@ def __init__(self): # Method handlers # this just adds a request ID to the request and response headers self.add_middleware(RequestIDMiddleware) - - # Optional observability (traces, metrics, logs), off unless sgp-obs is - # installed AND the SGP_OBS_* environment switches ask for it — see - # observability/sgp_obs_setup.py for the two gates. sgp-obs is deliberately - # not a dependency of this package; the agent declares it. Returns a status - # instead of raising: a telemetry problem must never stop an agent starting. - # - # Here rather than in the lifespan, deliberately: sgp-obs installs ASGI - # instrumentation via add_middleware, and Starlette raises "Cannot add middleware - # after an application has started" once the lifespan is running. Wiring it there - # loses http.server.* for the agent's own entry point — and loses it QUIETLY, - # because sgp-obs fails open. - init_sgp_obs(app=self) - self._handlers: dict[RPCMethod, Callable] = {} # Agent info to return in healthz @@ -262,20 +176,9 @@ async def lifespan_context(app: FastAPI): # noqa: ARG001 yield finally: await shutdown_default_span_queue() - # The queue above is the ASYNC path only. Sync tracing processors - # hold their own queue and nothing ever drained it, so a sync ACP - # agent lost whatever business spans were still queued when the pod - # stopped — including the ones the obs correlation points at. - await shutdown_sync_tracing_processors() - # Flush whatever sgp-obs still holds. A periodic exporter's buffer - # is otherwise dropped when the pod stops, which for a short-lived - # or scaled-to-zero agent can be most of what it recorded. No-op - # when sgp-obs is absent or was never wired. - await shutdown_sgp_obs() return lifespan_context - async def _healthz(self): """Health check endpoint""" result = {"status": "healthy"} diff --git a/src/agentex/lib/sdk/fastacp/base/tests/__init__.py b/src/agentex/lib/sdk/fastacp/base/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py b/src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py deleted file mode 100644 index 1fa359849..000000000 --- a/src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py +++ /dev/null @@ -1,344 +0,0 @@ -"""Tests for the ACP lifespan's shutdown drains. - -``shutdown_default_span_queue`` covers the async span path. The SYNC tracing -processors keep their own queue, and nothing in the SDK ever shut them down, so a -sync ACP agent dropped whatever business spans were still queued when the pod -stopped. That is worse than the spans themselves: the business span is what an obs -span's ``agentex.business_trace_id`` resolves to, so losing it breaks the pivot from -Tempo back to the SGP store. -""" - -from __future__ import annotations - -from agentex.lib.sdk.fastacp.base import base_acp_server -from agentex.lib.core.tracing.tracing_processor_manager import ( - shutdown_sync_tracing_processors, -) - - -def _block_sgp_obs_import(monkeypatch): - """Make `import sgp_obs` fail, i.e. the image a tokenless build produces.""" - import sys - import builtins - - monkeypatch.delitem(sys.modules, "sgp_obs", raising=False) - real_import = builtins.__import__ - - def blocked(name, *args, **kwargs): - if name == "sgp_obs" or name.startswith("sgp_obs."): - raise ImportError("No module named 'sgp_obs'") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", blocked) - - -class _Processor: - def __init__(self, explode: bool = False) -> None: - self.calls = 0 - self._explode = explode - - def shutdown(self) -> None: - self.calls += 1 - if self._explode: - raise RuntimeError("flush timed out") - - -def _patch_processors(monkeypatch, processors): - import agentex.lib.core.tracing.tracing_processor_manager as mgr - - monkeypatch.setattr(mgr, "get_sync_tracing_processors", lambda: processors) - - -class TestSyncProcessorDrain: - async def test_every_processor_is_flushed(self, monkeypatch): - a, b = _Processor(), _Processor() - _patch_processors(monkeypatch, [a, b]) - await shutdown_sync_tracing_processors() - assert (a.calls, b.calls) == (1, 1) - - async def test_one_failure_does_not_stop_the_others(self, monkeypatch): - """A processor that hangs or raises must not strand the spans held by the - ones after it in the list.""" - bad, good = _Processor(explode=True), _Processor() - _patch_processors(monkeypatch, [bad, good]) - await shutdown_sync_tracing_processors() - assert good.calls == 1 - - async def test_no_processors_is_a_no_op(self, monkeypatch): - _patch_processors(monkeypatch, []) - await shutdown_sync_tracing_processors() # must not raise - - async def test_an_unreadable_processor_list_does_not_fail_shutdown(self, monkeypatch): - """Nothing here may stop the pod from shutting down. - - This used to block the import of ``tracing_processor_manager``, which tested - nothing once the drain moved INTO that module: it reads - ``get_sync_tracing_processors`` as a module global, so the import never runs and - the ``except`` branch was never reached. Make the lookup itself raise instead.""" - import agentex.lib.core.tracing.tracing_processor_manager as mgr - - def boom(): - raise RuntimeError("processor registry unavailable") - - monkeypatch.setattr(mgr, "get_sync_tracing_processors", boom) - await shutdown_sync_tracing_processors() # must not raise - - def test_the_lifespan_calls_it(self): - """Pin the wiring, not just the helper: a drain nothing calls is worthless.""" - import inspect - - source = inspect.getsource(base_acp_server.BaseACPServer.get_lifespan_function) - assert "shutdown_sync_tracing_processors()" in source - assert "shutdown_sgp_obs()" in source - - -class TestTheDrainIsBounded: - """`SGPSyncTracingProcessor.shutdown` does a BLOCKING HTTP flush with retries. If - the drain waited on it inline and without a limit, a slow or unreachable collector - would burn the pod's whole termination grace period and the OTel flush that runs - after it would never happen — trading a few business spans for all of the OTel ones. - """ - - async def test_a_stalled_processor_does_not_hang_shutdown(self, monkeypatch): - import time - import asyncio - - class Stalled: - def shutdown(self): - time.sleep(2) # blocking, like a retrying HTTP flush - - _patch_processors(monkeypatch, [Stalled()]) - started = asyncio.get_running_loop().time() - await shutdown_sync_tracing_processors(budget_s=0.25) - elapsed = asyncio.get_running_loop().time() - started - assert elapsed < 1, f"drain took {elapsed:.1f}s against a 0.25s budget" - - async def test_the_budget_is_shared_so_a_stall_cannot_starve_the_rest(self, monkeypatch): - """A shared deadline means the drain as a whole is bounded, not each processor - separately — N stalled processors must not cost N * budget.""" - import time - import asyncio - - class Stalled: - def shutdown(self): - time.sleep(2) - - _patch_processors(monkeypatch, [Stalled(), Stalled(), Stalled()]) - started = asyncio.get_running_loop().time() - await shutdown_sync_tracing_processors(budget_s=0.25) - elapsed = asyncio.get_running_loop().time() - started - assert elapsed < 1, f"drain took {elapsed:.1f}s for 3 stalled processors" - - async def test_it_does_not_block_the_event_loop(self, monkeypatch): - """The flush must run off-loop: other lifespan work has to keep progressing - while a processor is stuck.""" - import time - import asyncio - - class Stalled: - def shutdown(self): - time.sleep(2) - - _patch_processors(monkeypatch, [Stalled()]) - ticks = 0 - - async def heartbeat(): - nonlocal ticks - while True: - await asyncio.sleep(0.01) - ticks += 1 - - beat = asyncio.create_task(heartbeat()) - await shutdown_sync_tracing_processors(budget_s=0.25) - beat.cancel() - assert ticks > 0, "the event loop was blocked during the drain" - - -class TestTheTemporalWorkerIsWiredToo: - """A Temporal agent runs its model calls in the worker process, which never - constructs a BaseACPServer. Without its own init the documented environment leaves - that process — the one doing the interesting work — completely unwired. - """ - - def test_the_worker_inits_and_drains(self): - import inspect - - from agentex.lib.core.temporal.workers.worker import AgentexWorker - - source = inspect.getsource(AgentexWorker.run) - assert "init_sgp_obs()" in source - assert "shutdown_sgp_obs()" in source - assert "shutdown_sync_tracing_processors()" in source - assert "shutdown_default_span_queue()" in source - - def test_the_worker_drains_the_async_queue_too(self): - """The one a Temporal worker most needs. Standard activities trace through - AsyncTracer (core/temporal/activities/__init__.py), and AsyncTrace takes - get_default_span_queue() when no queue is passed — so a worker's business spans - sit in the ASYNC queue, which this finally originally did not drain at all. - - Order matters as well as presence: the async queue is drained first, as the ACP - lifespan does, so the bounded drains that follow cannot eat its budget. - """ - import inspect - - from agentex.lib.core.temporal.workers.worker import AgentexWorker - - source = inspect.getsource(AgentexWorker.run) - async_at = source.index("shutdown_default_span_queue()") - sync_at = source.index("shutdown_sync_tracing_processors()") - obs_at = source.index("shutdown_sgp_obs()") - assert async_at < sync_at < obs_at, ( - "the worker's finally must drain async queue -> sync processors -> sgp-obs, " - "matching the ACP lifespan" - ) - - def test_the_worker_matches_the_acp_lifespan(self): - """The two shutdown paths drifting apart is how the async queue came to be - missing here in the first place.""" - import inspect - - from agentex.lib.core.temporal.workers.worker import AgentexWorker - from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer - - drains = ( - "shutdown_default_span_queue()", - "shutdown_sync_tracing_processors()", - "shutdown_sgp_obs()", - ) - worker = inspect.getsource(AgentexWorker.run) - lifespan = inspect.getsource(BaseACPServer.get_lifespan_function) - for drain in drains: - assert drain in worker, f"worker is missing {drain}" - assert drain in lifespan, f"ACP lifespan is missing {drain}" - - def test_the_worker_does_not_pass_an_app(self): - """There is no ASGI application in the worker process. The health-check server - is aiohttp, which sgp-obs' ASGI middleware does not apply to, so passing it - would be wrong rather than merely useless.""" - import inspect - - from agentex.lib.core.temporal.workers.worker import AgentexWorker - - source = inspect.getsource(AgentexWorker.run) - assert "init_sgp_obs(app=" not in source - - -class TestConcurrencyAndProcessExit: - """Two properties the budget only really has if these hold.""" - - async def test_a_fast_processor_finishes_even_when_another_stalls(self, monkeypatch): - """Flushes start concurrently under ONE shared deadline. Draining them in - sequence let the first stalled processor spend the whole budget, so every - processor after it was skipped even when it would have returned instantly.""" - import time - - class Stalled: - def shutdown(self): - time.sleep(2) - - class Fast: - def __init__(self): - self.flushed = False - - def shutdown(self): - self.flushed = True - - fast = Fast() - # Stalled FIRST: in a sequential drain it would eat the budget and `fast` - # would never be asked. - _patch_processors(monkeypatch, [Stalled(), fast]) - await shutdown_sync_tracing_processors(budget_s=0.5) - assert fast.flushed, "a fast processor was starved by a stalled one" - - def test_a_stalled_flush_does_not_delay_process_exit(self): - """The property the deadline actually promises, and the one it did NOT have. - - `asyncio.wait_for` stops awaiting a thread; it cannot stop the thread. And - `asyncio.run` joins the default executor on the way out (as does a private - ThreadPoolExecutor, via its atexit hook), so a timed-out `asyncio.to_thread` - flush left the process blocked on the very export the budget was meant to - escape — measured at 10.0s against a 0.25s budget. Daemon threads are abandoned - at interpreter exit, which is what the budget promises. - - A subprocess, because this is about interpreter shutdown: it cannot be observed - from inside the test process. - """ - import os - import sys - import time - import textwrap - import subprocess - from pathlib import Path - - # tests/base/fastacp/sdk/lib/agentex/src -> parents[6] is the src root. - src = Path(__file__).resolve().parents[6] - program = textwrap.dedent( - """ - import asyncio, sys, time - from agentex.lib.core.tracing.tracing_processor_manager import ( - shutdown_sync_tracing_processors, - ) - import agentex.lib.core.tracing.tracing_processor_manager as mgr - - class Stalled: - def shutdown(self): - time.sleep(30) - - mgr.get_sync_tracing_processors = lambda: [Stalled()] - asyncio.run(shutdown_sync_tracing_processors(budget_s=0.25)) - """ - ) - started = time.monotonic() - proc = subprocess.run( - [sys.executable, "-c", program], - capture_output=True, - text=True, - timeout=30, - # Inherit the environment: replacing it wholesale breaks the - # interpreter's own bootstrap before the test can run. - env={**os.environ, "PYTHONPATH": str(src)}, - ) - elapsed = time.monotonic() - started - assert proc.returncode == 0, proc.stderr[-2000:] - assert elapsed < 10, ( - f"process took {elapsed:.1f}s to exit with a 30s stalled flush and a " - "0.25s budget; the flush thread is blocking interpreter shutdown" - ) - - -class TestTheWorkerObsPathRunsWithoutSgpObs: - """The image a build with NO broker token produces has no sgp-obs in it, and a - Temporal agent's model calls happen in this process. - - The two tests above pin that ``run()`` *calls* these, by reading its source. That - cannot catch a call that is written correctly and then raises, so this exercises the - sequence for real. Together: one proves the wiring exists, the other proves it is - harmless. - """ - - def test_the_worker_module_imports_and_constructs(self): - from agentex.lib.core.temporal.workers.worker import AgentexWorker - - # port 0 so nothing binds a real health port during the test - assert AgentexWorker(task_queue="probe", health_check_port=0) is not None - - async def test_init_and_both_drains_are_inert(self, monkeypatch): - """Exactly what ``run()`` does: init at entry, both drains in its finally — - with nothing wired, which is every agent that has not adopted.""" - from agentex.lib.core.observability import sgp_obs_setup - from agentex.lib.core.observability.sgp_obs_setup import ( - init_sgp_obs, - shutdown_sgp_obs, - ) - - monkeypatch.delenv("SGP_OBS_ENABLED", raising=False) - sgp_obs_setup._reset_for_tests() - try: - _block_sgp_obs_import(monkeypatch) - assert init_sgp_obs() == "not_installed" - # Neither drain may raise just because nothing was ever wired. - await shutdown_sync_tracing_processors() - await shutdown_sgp_obs() - finally: - sgp_obs_setup._reset_for_tests() diff --git a/src/agentex/lib/types/agent_card.py b/src/agentex/lib/types/agent_card.py index d0af817a5..def4464c6 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 Field, BaseModel +from pydantic import BaseModel if TYPE_CHECKING: from agentex.lib.sdk.state_machine.state import State @@ -31,11 +31,6 @@ 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( @@ -45,7 +40,6 @@ 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. @@ -87,7 +81,6 @@ 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 @@ -97,7 +90,6 @@ 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() @@ -133,7 +125,6 @@ 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/build_provenance.py b/src/agentex/lib/utils/build_provenance.py index 37b61a3f9..447980263 100644 --- a/src/agentex/lib/utils/build_provenance.py +++ b/src/agentex/lib/utils/build_provenance.py @@ -82,8 +82,7 @@ def normalize_remote(url: Optional[str]) -> Optional[str]: """Strip credentials and scheme from a remote, returning ``host/path``.""" if not url: return None - # Query strings and fragments never name a repo, but they do carry tokens. - candidate = url.strip().split("?", 1)[0].split("#", 1)[0] + candidate = url.strip() # 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/logging.py b/src/agentex/lib/utils/logging.py index e2d5d5cb4..5bbaf61ac 100644 --- a/src/agentex/lib/utils/logging.py +++ b/src/agentex/lib/utils/logging.py @@ -11,59 +11,6 @@ ctx_var_request_id = contextvars.ContextVar[str]("request_id") -DEFAULT_LOG_LEVEL = logging.INFO - -# Every logger this module hands out is a LEAF (``make_logger(__name__)``), and until -# now each one carried its own handler. That is fine on its own, but an observability -# pipeline that owns the ROOT logger -- sgp-obs replaces the root handler list -- then -# prints a SECOND copy of every record: once here, and once more when the record -# propagates to root. Measured on sgp-obs 0.16.0: one ``logger.info()`` produced two -# stdout lines, and sgp-obs' own boot warning named 63 loggers "bypassing log -# governance". The plain-text copy also skips the pipeline's enrichment (agent_id, -# task_id), its allowlist and its truncation, so it is not merely redundant. -# -# While this is True, ``make_logger`` attaches nothing and the record reaches the root -# pipeline by propagation alone. ``sgp_obs_setup`` sets it via -# :func:`route_loggers_to_root` -- nothing else may. -_ROOT_PIPELINE_OWNS_LOGGING = False - -# Handlers are cleared by prefix rather than by an enumerated list: the names are -# module paths, several agentex modules are imported LAZILY, and any list would be a -# snapshot that goes stale the moment one of them loads. -_PACKAGE_ROOT = "agentex" - -# ``make_logger`` stamps every handler it attaches, so the hand-over can find its own -# handlers again on a logger of ANY name. -# -# The prefix above cannot reach them all, and that gap was a measured duplicate rather -# than a theoretical one: agents call ``make_logger(__name__)`` from their own modules, -# whose names come from the agent's package (``project.acp`` in every scaffold), so the -# prefix does not match and the leaf handler stayed attached. On dbt-assistant, 123 of -# 3361 log lines were a second, ungoverned copy carrying ``name``/``request_id`` but no -# ``trace_id``, ``span_id``, ``source`` or ``agent_id``. The SDK cannot know an agent's -# package name, so ownership is recorded on the handler at the moment it is attached. -# -# Marking the handler rather than keeping a registry of logger names means there is no -# bookkeeping to go stale, and a handler moved to another logger is still recognised. -_OWNED_BY_MAKE_LOGGER = "_agentex_make_logger_owned" - - -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] @@ -96,17 +43,6 @@ def json_record(self, message: str, extra: dict, record: logging.LogRecord) -> d return extra - -def _attach(logger: logging.Logger, handler: logging.Handler) -> None: - """Attach ``handler`` and record that this module owns it. - - The mark is what lets :func:`route_loggers_to_root` take this handler back off a - logger whose name it could not have predicted. - """ - setattr(handler, _OWNED_BY_MAKE_LOGGER, True) - logger.addHandler(handler) - - def make_logger(name: str) -> logging.Logger: """ Creates a logger object with a RichHandler to print colored text. @@ -115,28 +51,19 @@ def make_logger(name: str) -> logging.Logger: """ # Create a console object to print colored text logger = logging.getLogger(name) - logger.setLevel(resolve_log_level()) - - if _ROOT_PIPELINE_OWNS_LOGGING: - # A handler here would be the second one on this record's path to stdout. - # The level above is deliberately still applied: LOG_LEVEL is what agent - # authors set, and letting the pipeline's own threshold silently replace it - # would change behaviour nobody asked to change. - return logger + logger.setLevel(logging.INFO) environment = os.getenv("ENVIRONMENT") if environment == "local": console = Console() # Add the RichHandler to the logger to print colored text - _attach( - logger, - RichHandler( - console=console, - show_level=False, - show_path=False, - show_time=False, - ), + handler = RichHandler( + console=console, + show_level=False, + show_path=False, + show_time=False, ) + logger.addHandler(handler) return logger stream_handler = logging.StreamHandler() @@ -147,76 +74,6 @@ def make_logger(name: str) -> logging.Logger: logging.Formatter("%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] - %(message)s") ) - _attach(logger, stream_handler) + logger.addHandler(stream_handler) # Create a logger object with the name of the current module return logger - - -def route_loggers_to_root() -> int: - """Hand logging over to whatever owns the root logger. Returns the number of - loggers a handler was taken off. - - Two halves, and BOTH are needed -- measured, one line per ``logger.info()`` only - when they run together: - - * the sweep below fixes the loggers that ALREADY exist, i.e. every module whose - ``make_logger`` call ran before this did -- the whole of an agent's own code, - since the ACP server is constructed from a module that logs; - * the latch fixes every logger created AFTER it, which a sweep cannot reach. - agentex imports several modules lazily (the adk ``_claude_code_sync`` / - ``_codex_sync`` / ``_pydantic_ai_sync`` harnesses among them), so their - ``make_logger`` call happens later and would attach a fresh duplicate handler. - - sgp-obs offers ``capture_loggers=`` for the first half, and it is deliberately not - used: it matches EXACT logger names, not prefixes (measured -- passing - ``("agentex",)`` still produced two lines), so it would mean enumerating ~60 module - paths; and passing anything at all replaces its uvicorn default, which would put - uvicorn's access log back to printing twice. - - A handler is taken off only when it is ours, on one of two grounds: - - * anything under the ``agentex`` prefix is this package's own logger, so every - handler on it is ours to move; - * on a logger of any other name -- an agent's ``project.acp``, or any third - party's -- only a handler carrying :data:`_OWNED_BY_MAKE_LOGGER` is touched. - - That second rule is the fix for the duplicate measured on dbt-assistant, and it is - narrow on purpose. A third party's handler may be there deliberately -- which is - exactly why sgp-obs warns about them rather than stripping them -- so litellm's - three loggers and anything else keep whatever they set up themselves. - """ - global _ROOT_PIPELINE_OWNS_LOGGING - _ROOT_PIPELINE_OWNS_LOGGING = True - - cleared = 0 - # list() snapshots the registry: a getLogger() on another thread would otherwise - # mutate the dict mid-iteration. - for name, existing in list(logging.Logger.manager.loggerDict.items()): - if not isinstance(existing, logging.Logger): - continue # a PlaceHolder for a name whose children exist but itself does not - if not existing.handlers: - continue - if not existing.propagate: - # Deliberately cut off from root, so nothing of its reaches the pipeline. - # Clearing its handlers would send its records NOWHERE -- worse than a - # duplicate. Leave it exactly as its owner set it up. - continue - ours = name == _PACKAGE_ROOT or name.startswith(_PACKAGE_ROOT + ".") - removed = 0 - for handler in list(existing.handlers): - if not ours and not getattr(handler, _OWNED_BY_MAKE_LOGGER, False): - continue - try: - handler.flush() # a buffering handler must not lose records on removal - except Exception: - pass - existing.removeHandler(handler) - removed += 1 - if removed: - cleared += 1 - return cleared - - -def _reset_for_tests() -> None: - global _ROOT_PIPELINE_OWNS_LOGGING - _ROOT_PIPELINE_OWNS_LOGGING = False diff --git a/src/agentex/lib/utils/metadata_filters.py b/src/agentex/lib/utils/metadata_filters.py deleted file mode 100644 index 22d8aeb59..000000000 --- a/src/agentex/lib/utils/metadata_filters.py +++ /dev/null @@ -1,58 +0,0 @@ -"""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/src/agentex/lib/utils/registration.py b/src/agentex/lib/utils/registration.py index 36b5f9a04..5fc4d4be5 100644 --- a/src/agentex/lib/utils/registration.py +++ b/src/agentex/lib/utils/registration.py @@ -7,8 +7,6 @@ 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__) @@ -22,29 +20,6 @@ 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: @@ -58,7 +33,13 @@ async def register_agent(env_vars: EnvironmentVariables, agent_card=None): or f"Generic description for agent: {env_vars.AGENT_NAME}" ) - registration_metadata = build_registration_metadata(env_vars, agent_card) + # 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 # Prepare registration data registration_data = { diff --git a/src/agentex/lib/utils/tests/__init__.py b/src/agentex/lib/utils/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/utils/tests/test_logging_handover.py b/src/agentex/lib/utils/tests/test_logging_handover.py deleted file mode 100644 index 265237322..000000000 --- a/src/agentex/lib/utils/tests/test_logging_handover.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Tests for handing the process's loggers over to a root logging pipeline. - -``make_logger`` attaches a handler to each module's OWN (leaf) logger. sgp-obs' logs -pipeline replaces the handlers on the ROOT logger and deliberately leaves named loggers -alone, on the grounds that a named logger's handler may be there on purpose. Each is -defensible; together they print every record twice — once in agentex's plain text from -the leaf, once as pipeline JSON from root. Measured on sgp-obs 0.16.0: one -``logger.info()`` produced two stdout lines and sgp-obs named 63 loggers as "bypassing -log governance". - -The duplicate is not merely redundant. It is emitted before the pipeline's filters, so -it carries no ``agent_id``/``task_id``, is not governed by the allowlist, and is not -truncated. - -The fix has two halves and needs both, which is what the subprocess tests pin: - -* the sweep clears loggers that ALREADY exist when it runs; -* the latch stops ``make_logger`` attaching to loggers created AFTERWARDS. - -A sweep alone misses the second: agentex imports several harness modules lazily, so -their ``make_logger`` runs later and would attach a fresh duplicate. - -Both halves have to cover an agent's OWN loggers, not just ``agentex.*``. The agent -calls ``make_logger(__name__)`` from modules named for its own package, and a sweep that -matched only the ``agentex`` prefix left those printing twice: measured on dbt-assistant -running 0.27.0b1, 123 of 3361 log lines were the ungoverned copy, all of them from -``project.acp``. The latch already covered them (it does not look at the name); the -sweep did not, because ``project.acp``'s ``make_logger`` runs at import, before the ACP -server is constructed and ``init_sgp_obs`` runs. -""" - -from __future__ import annotations - -import os -import sys -import logging -import textwrap -import subprocess -from typing import override -from pathlib import Path - -import pytest - -from agentex.lib.utils import logging as agentex_logging -from agentex.lib.utils.logging import make_logger, route_loggers_to_root - -_SRC = Path(__file__).resolve().parents[4] - - -@pytest.fixture(autouse=True) -def _restore_logging(): - """The latch and the loggers are process-wide; put both back.""" - saved = { - name: (obj.handlers[:], obj.propagate) - for name, obj in logging.Logger.manager.loggerDict.items() - if isinstance(obj, logging.Logger) - } - try: - yield - finally: - agentex_logging._reset_for_tests() - for name, (handlers, propagate) in saved.items(): - existing = logging.Logger.manager.loggerDict.get(name) - if isinstance(existing, logging.Logger): - existing.handlers[:] = handlers - existing.propagate = propagate - - -def _run(handover: bool) -> str: - """One trial in its own process — root-logger state is global and cannot be - isolated within a test session. Returns stdout+stderr.""" - program = textwrap.dedent( - f""" - import logging, sys - from agentex.lib.utils.logging import make_logger, route_loggers_to_root - - # Exists BEFORE the handover, like any eagerly-imported agentex module. - before = make_logger("agentex.lib.probe.before") - - # The agent's own module, which is where the measured duplicate came from: - # its make_logger runs at import, so it always predates the handover. - agent = make_logger("project.acp") - - # Stand in for sgp-obs' pipeline: a single handler on ROOT. - root = logging.getLogger() - root.handlers[:] = [logging.StreamHandler(sys.stdout)] - root.setLevel(logging.INFO) - - if {handover!r}: - route_loggers_to_root() - - # Created AFTER, like one of the lazily-imported harness modules. - after = make_logger("agentex.lib.probe.after") - - before.info("MARKER-BEFORE") - agent.info("MARKER-AGENT") - after.info("MARKER-AFTER") - """ - ) - proc = subprocess.run( - [sys.executable, "-c", program], - capture_output=True, - text=True, - timeout=60, - env={**os.environ, "PYTHONPATH": str(_SRC), "LOG_LEVEL": "INFO", "ENVIRONMENT": "production"}, - ) - assert proc.returncode == 0, proc.stderr[-2000:] - return proc.stdout + proc.stderr - - -class TestEveryRecordIsPrintedOnce: - def test_without_the_handover_everything_doubles(self): - """The bug, pinned. If this ever reads 1, the other tests below have stopped - proving anything.""" - out = _run(handover=False) - assert out.count("MARKER-BEFORE") == 2 - assert out.count("MARKER-AGENT") == 2 - assert out.count("MARKER-AFTER") == 2 - - def test_a_logger_created_before_the_handover_prints_once(self): - out = _run(handover=True) - assert out.count("MARKER-BEFORE") == 1 - - def test_an_agents_own_logger_prints_once(self): - """The regression measured on dbt-assistant: ``project.acp`` is not under the - ``agentex`` prefix, so a prefix-only sweep left its handler attached and every - record it logged was printed twice.""" - out = _run(handover=True) - assert out.count("MARKER-AGENT") == 1 - - def test_a_logger_created_after_the_handover_prints_once(self): - """The half a sweep cannot reach: agentex imports harness modules lazily, so - their make_logger runs after init and would attach a fresh duplicate.""" - out = _run(handover=True) - assert out.count("MARKER-AFTER") == 1 - - -class TestTheSweepIsNarrow: - def test_it_clears_an_agentex_logger_that_has_a_handler(self): - lg = logging.getLogger("agentex.lib.probe.sweep") - lg.addHandler(logging.NullHandler()) - assert route_loggers_to_root() >= 1 - assert lg.handlers == [] - - def test_it_clears_our_own_handler_from_a_logger_of_any_name(self): - """``make_logger`` marks what it attaches, which is the only way to find it - again on a logger named for the agent's package rather than for agentex.""" - lg = make_logger("project.acp") - assert lg.handlers != [] - assert route_loggers_to_root() >= 1 - assert lg.handlers == [] - - def test_it_leaves_other_packages_alone(self): - """A third party's handler may be deliberate — which is exactly why sgp-obs - warns about them rather than stripping them.""" - other = logging.getLogger("litellm.probe") - handler = logging.NullHandler() - other.addHandler(handler) - route_loggers_to_root() - assert other.handlers == [handler] - - def test_it_takes_only_its_own_handler_off_a_shared_logger(self): - """An agent may have added a handler of its own next to ours. Ours goes, the - agent's stays exactly where it put it.""" - lg = make_logger("project.shared") - theirs = logging.NullHandler() - lg.addHandler(theirs) - route_loggers_to_root() - assert lg.handlers == [theirs] - - def test_it_leaves_a_non_propagating_agentex_logger_alone(self): - """Cut off from root on purpose, so nothing of its reaches the pipeline. - Clearing its handlers would send its records NOWHERE — worse than a duplicate.""" - lg = logging.getLogger("agentex.lib.probe.isolated") - handler = logging.NullHandler() - lg.addHandler(handler) - lg.propagate = False - route_loggers_to_root() - assert lg.handlers == [handler] - - def test_it_leaves_a_non_propagating_logger_of_ours_alone(self): - """Same reasoning, for a logger the agent cut off from root after asking us for - it: our handler is the only route its records have.""" - lg = make_logger("project.isolated") - ours = lg.handlers[:] - lg.propagate = False - route_loggers_to_root() - assert lg.handlers == ours - - def test_a_prefix_lookalike_gets_no_blanket_sweep(self): - """`agentexfoo` is a different package, not a child of `agentex`, so only a - handler of ours would be taken off it — and this one is not.""" - lg = logging.getLogger("agentexfoo.probe") - handler = logging.NullHandler() - lg.addHandler(handler) - route_loggers_to_root() - assert lg.handlers == [handler] - - def test_handlers_are_flushed_before_removal(self): - """A buffering handler would otherwise lose whatever it was holding.""" - flushed = [] - - class Recording(logging.NullHandler): - @override - def flush(self): - flushed.append(True) - - lg = logging.getLogger("agentex.lib.probe.flush") - lg.addHandler(Recording()) - route_loggers_to_root() - assert flushed == [True] - - -class TestMakeLoggerRespectsTheLatch: - def test_it_attaches_nothing_once_the_pipeline_owns_logging(self): - route_loggers_to_root() - assert make_logger("agentex.lib.probe.after_latch").handlers == [] - - def test_it_attaches_nothing_for_an_agents_own_logger_either(self): - """The latch never looked at the name, so this half already covered the agent's - lazily-imported modules; pinned so it stays that way.""" - route_loggers_to_root() - assert make_logger("project.after_latch").handlers == [] - - def test_it_still_attaches_when_nothing_owns_logging(self): - """The non-negotiable half: an agent without sgp-obs must log exactly as it - did before any of this existed.""" - agentex_logging._reset_for_tests() - assert make_logger("agentex.lib.probe.no_latch").handlers != [] - - def test_the_level_is_applied_either_way(self, monkeypatch): - """LOG_LEVEL is what agent authors set; letting the pipeline's own threshold - silently replace it would change behaviour nobody asked to change.""" - monkeypatch.setenv("LOG_LEVEL", "DEBUG") - route_loggers_to_root() - assert make_logger("agentex.lib.probe.level").level == logging.DEBUG diff --git a/tests/lib/cli/test_deploy_handlers.py b/tests/lib/cli/test_deploy_handlers.py deleted file mode 100644 index 835b56ae8..000000000 --- a/tests/lib/cli/test_deploy_handlers.py +++ /dev/null @@ -1,64 +0,0 @@ -"""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/cli/test_run_handlers_streaming.py b/tests/lib/cli/test_run_handlers_streaming.py deleted file mode 100644 index 8f0ab13b5..000000000 --- a/tests/lib/cli/test_run_handlers_streaming.py +++ /dev/null @@ -1,180 +0,0 @@ -"""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/core/temporal/test_workflow_logging.py b/tests/lib/core/temporal/test_workflow_logging.py deleted file mode 100644 index 6b193b35e..000000000 --- a/tests/lib/core/temporal/test_workflow_logging.py +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 3774d3689..000000000 --- a/tests/lib/core/temporal/test_workflow_logging_replay.py +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 0242fd01b..000000000 --- a/tests/lib/core/temporal/workers/test_worker_tracing.py +++ /dev/null @@ -1,103 +0,0 @@ -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/temporal/workers/test_worker_version_guard.py b/tests/lib/core/temporal/workers/test_worker_version_guard.py index 5c2c9fb47..4ab5fc435 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, agent_card=None) + register.assert_awaited_once_with(env) assert order == ["guard", "register"] # guard must precede registration 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 7b5c129d6..e8a3fb08d 100644 --- a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py +++ b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py @@ -54,15 +54,17 @@ def test_agent_identity_and_version_stamped_into_span_data(self): "__agent_version__": "sha-abc123", } - SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + # Abbreviated deliberately: a bare 40-char hex literal trips credential + # scanners, and code_revision accepts any git object name (7-64 hex). + SHA = "b362b171a9c4" - 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.""" + 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.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) 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 a696129d7..0b89b88f2 100644 --- a/tests/lib/core/tracing/test_code_revision.py +++ b/tests/lib/core/tracing/test_code_revision.py @@ -1,8 +1,7 @@ -"""Commit-SHA stamping. +"""Opt-in commit-SHA stamping. -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. +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 @@ -22,33 +21,14 @@ def _reset(): code_revision.disable() -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) +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) - 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 7246d7c32..5d57f9e8e 100644 --- a/tests/lib/test_agent_card.py +++ b/tests/lib/test_agent_card.py @@ -189,7 +189,6 @@ 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"]) @@ -197,34 +196,6 @@ 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 --- @@ -276,14 +247,6 @@ 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( @@ -352,13 +315,6 @@ 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 --- @@ -377,8 +333,6 @@ 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 @@ -416,20 +370,6 @@ 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 b0bf47a63..370bd5e60 100644 --- a/tests/lib/test_agentex_worker.py +++ b/tests/lib/test_agentex_worker.py @@ -117,169 +117,6 @@ 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 - env.AGENT_COMMIT_SHA = None - env.AGENT_SOURCE_REPO = 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_build_provenance.py b/tests/lib/test_build_provenance.py index 1bf3629d0..ae869320d 100644 --- a/tests/lib/test_build_provenance.py +++ b/tests/lib/test_build_provenance.py @@ -48,9 +48,8 @@ def _write(root: Path, rel: str, content: str = "x") -> None: [ ("git@github.com:scaleapi/Repo.git", "github.com/scaleapi/Repo"), ("https://github.com/scaleapi/Repo.git", "github.com/scaleapi/Repo"), - ("https://x-token:secret@GitHub.com/scaleapi/Repo", "github.com/scaleapi/Repo"), + ("https://x-token:secret@GitHub.com/scaleapi/Repo", "github.com/scaleapi/Repo"), # trufflehog:ignore ("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 deleted file mode 100644 index c0d2140a1..000000000 --- a/tests/lib/test_client_timeout_env.py +++ /dev/null @@ -1,102 +0,0 @@ -"""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/test_metadata_filters.py b/tests/lib/test_metadata_filters.py deleted file mode 100644 index 34398187f..000000000 --- a/tests/lib/test_metadata_filters.py +++ /dev/null @@ -1,112 +0,0 @@ -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"] == "{}" diff --git a/tests/lib/utils/test_logging_level.py b/tests/lib/utils/test_logging_level.py deleted file mode 100644 index 16b171e33..000000000 --- a/tests/lib/utils/test_logging_level.py +++ /dev/null @@ -1,66 +0,0 @@ -"""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) diff --git a/tests/lib/utils/test_registration.py b/tests/lib/utils/test_registration.py deleted file mode 100644 index 65960d757..000000000 --- a/tests/lib/utils/test_registration.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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"}, - } diff --git a/tests/test_client.py b/tests/test_client.py index 131d32fee..7c0177453 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -719,7 +719,7 @@ def test_base_url_env(self) -> None: Agentex(api_key=api_key, _strict_response_validation=True, environment="production") client = Agentex(base_url=None, api_key=api_key, _strict_response_validation=True, environment="production") - assert str(client.base_url).startswith("http://localhost:5003") + assert str(client.base_url).startswith("https://agentex.sgp.scale.com") client.close() @@ -1652,7 +1652,7 @@ async def test_base_url_env(self) -> None: client = AsyncAgentex( base_url=None, api_key=api_key, _strict_response_validation=True, environment="production" ) - assert str(client.base_url).startswith("http://localhost:5003") + assert str(client.base_url).startswith("https://agentex.sgp.scale.com") await client.close() diff --git a/tests/test_request_id_correlation.py b/tests/test_request_id_correlation.py deleted file mode 100644 index b281cf94e..000000000 --- a/tests/test_request_id_correlation.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Unit tests for handing the ACP request id to the observability pipeline. - -``request_id`` used to reach the logs through exactly one writer: ``CustomJSONFormatter`` -on the handler ``make_logger`` attaches to each module's own logger. That handler is -taken off once a logs pipeline owns the root logger, because it was printing a second, -ungoverned copy of every record — so the id has to be handed over, or it is simply lost. -Measured on dbt-assistant running 0.27.0b1, ``request_id`` was on 5.2% of log lines, -which were exactly the ungoverned copies. - -The hand-over target is sgp-obs' shared correlation context (``sgp_obs.context``: -``bind(**fields) -> Token``, ``reset(token)``, ``current()``), stubbed here because -sgp-obs is an optional install and is deliberately not a dependency of this package. -""" - -from __future__ import annotations - -from typing import Any -from contextvars import ContextVar - -import pytest - -from agentex.lib.utils.logging import ctx_var_request_id -from agentex.lib.sdk.fastacp.base import base_acp_server -from agentex.lib.sdk.fastacp.base.base_acp_server import ( - RequestIDMiddleware, - _bind_request_id_for_telemetry, - _unbind_request_id_for_telemetry, -) - - -class StubObsContext: - """The shape of ``sgp_obs.context`` that this SDK uses, over a real ContextVar so - "was the id in scope while the request ran?" is a real question.""" - - def __init__(self) -> None: - self._var: ContextVar[str | None] = ContextVar("stub_request_id", default=None) - self.binds: list[dict[str, Any]] = [] - self.resets = 0 - - def bind(self, **fields: Any) -> object: - self.binds.append(fields) - return self._var.set(fields.get("request_id")) - - def reset(self, token: Any) -> None: - self.resets += 1 - self._var.reset(token) - - def current(self) -> str | None: - return self._var.get() - - -@pytest.fixture -def obs(monkeypatch: pytest.MonkeyPatch) -> StubObsContext: - stub = StubObsContext() - # The memo is the seam: the helpers resolve `sgp_obs.context` once per process. - monkeypatch.setattr(base_acp_server, "_obs_context_module", stub) - return stub - - -def test_the_request_id_is_bound_for_the_pipeline(obs: StubObsContext) -> None: - token = _bind_request_id_for_telemetry("req-abc") - try: - assert obs.binds == [{"request_id": "req-abc"}] - assert obs.current() == "req-abc" - finally: - _unbind_request_id_for_telemetry(token) - - -def test_it_is_unbound_again(obs: StubObsContext) -> None: - _unbind_request_id_for_telemetry(_bind_request_id_for_telemetry("req-abc")) - assert obs.resets == 1 - assert obs.current() is None - - -def test_an_absent_sgp_obs_is_fail_open(monkeypatch: pytest.MonkeyPatch) -> None: - """The normal case for an agent that has not installed it.""" - monkeypatch.setattr(base_acp_server, "_obs_context_module", None) - assert _bind_request_id_for_telemetry("req-abc") is None - _unbind_request_id_for_telemetry(None) # must be safe - - -def test_a_raising_bind_does_not_break_the_request(monkeypatch: pytest.MonkeyPatch) -> None: - """``bind`` rejects unknown field names, so a future rename must degrade to no - correlation rather than to a failed request.""" - - class Raising: - def bind(self, **_fields: Any) -> object: - raise TypeError("unexpected keyword argument") - - def reset(self, _token: Any) -> None: - raise AssertionError("nothing to reset") - - monkeypatch.setattr(base_acp_server, "_obs_context_module", Raising()) - assert _bind_request_id_for_telemetry("req-abc") is None - - -@pytest.mark.asyncio -async def test_the_middleware_binds_the_same_id_it_gives_application_code( - obs: StubObsContext, -) -> None: - """One generator for the value: the id in the logs is the id the SDK's own - contextvar hands to the agent, and the id ``x-request-id`` carried in.""" - seen: dict[str, Any] = {} - - async def app(_scope: Any, _receive: Any, _send: Any) -> None: - seen["sdk"] = ctx_var_request_id.get(None) - seen["obs"] = obs.current() - - scope = {"type": "http", "headers": [(b"x-request-id", b"req-from-the-gateway")]} - await RequestIDMiddleware(app)(scope, None, None) # type: ignore[arg-type] - - assert seen["obs"] == "req-from-the-gateway" - assert seen["sdk"] == seen["obs"] - # Bound for the request only, so a later record cannot inherit a stale id. - assert obs.current() is None - assert obs.resets == 1 - - -@pytest.mark.asyncio -async def test_a_generated_id_is_bound_when_the_header_is_absent(obs: StubObsContext) -> None: - seen: dict[str, Any] = {} - - async def app(_scope: Any, _receive: Any, _send: Any) -> None: - seen["obs"] = obs.current() - - await RequestIDMiddleware(app)({"type": "http", "headers": []}, None, None) # type: ignore[arg-type] - assert seen["obs"] - - -@pytest.mark.asyncio -async def test_a_non_http_scope_binds_nothing(obs: StubObsContext) -> None: - """Lifespan and websocket scopes have no request id to bind.""" - - async def app(_scope: Any, _receive: Any, _send: Any) -> None: - return None - - await RequestIDMiddleware(app)({"type": "lifespan"}, None, None) # type: ignore[arg-type] - assert obs.binds == [] - assert obs.resets == 0 - - -@pytest.mark.asyncio -async def test_it_is_unbound_even_when_the_request_raises(obs: StubObsContext) -> None: - async def app(_scope: Any, _receive: Any, _send: Any) -> None: - raise RuntimeError("handler blew up") - - with pytest.raises(RuntimeError): - await RequestIDMiddleware(app)({"type": "http", "headers": []}, None, None) # type: ignore[arg-type] - assert obs.resets == 1 - assert obs.current() is None